Tuned ui to support ecommerce product catalogue and purchases.

This commit is contained in:
2026-09-01 21:11:32 +05:30
parent 3a5e812947
commit b4772ccf19
16 changed files with 1798 additions and 600 deletions

View File

@@ -21,6 +21,8 @@ class InventoryItem {
final int? vendorId;
final int? branchId;
final String? purchaseRef;
final String? salesRef;
final double? saleRate;
final String? photoUrl;
final String? status;
final DateTime? createdAt;
@@ -48,6 +50,8 @@ class InventoryItem {
this.vendorId,
this.branchId,
this.purchaseRef,
this.salesRef,
this.saleRate,
this.photoUrl,
this.status,
this.createdAt,
@@ -77,6 +81,8 @@ class InventoryItem {
vendorId: json['vendorId'],
branchId: json['branchId'],
purchaseRef: json['purchaseRef'],
salesRef: json['salesRef'] ?? json['sales_ref'],
saleRate: (json['saleRate'] ?? json['sale_rate'])?.toDouble(),
photoUrl: json['photoUrl'] ?? json['photo_url'],
status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,

View File

@@ -14,6 +14,26 @@ class Product {
final String? dimensions;
final String? color;
final String? size;
final double? itemLength;
final double? itemWidth;
final double? itemHeight;
final String? dimensionUom;
final double? packageLength;
final double? packageWidth;
final double? packageHeight;
final String? packageDimensionUom;
final String? manufacturerCode;
final String? material;
final String? brandName;
final String? countryOfOrigin;
final double? weightInG;
final double? packageWeightInG;
final double? volumetricWeightInKg;
final String priceCalcRule;
final bool autoCalculatePrice;
final double? purityFactor;
@@ -39,6 +59,21 @@ class Product {
this.dimensions,
this.color,
this.size,
this.itemLength,
this.itemWidth,
this.itemHeight,
this.dimensionUom = 'cm',
this.packageLength,
this.packageWidth,
this.packageHeight,
this.packageDimensionUom = 'cm',
this.manufacturerCode,
this.material,
this.brandName,
this.countryOfOrigin,
this.weightInG,
this.packageWeightInG,
this.volumetricWeightInKg,
this.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false,
this.purityFactor,
@@ -67,6 +102,21 @@ class Product {
dimensions: json['dimensions'],
color: json['color'],
size: json['size'],
itemLength: (json['itemLength'] ?? json['item_length'] as num?)?.toDouble(),
itemWidth: (json['itemWidth'] ?? json['item_width'] as num?)?.toDouble(),
itemHeight: (json['itemHeight'] ?? json['item_height'] as num?)?.toDouble(),
dimensionUom: json['dimensionUom'] ?? json['dimension_uom'] ?? 'cm',
packageLength: (json['packageLength'] ?? json['package_length'] as num?)?.toDouble(),
packageWidth: (json['packageWidth'] ?? json['package_width'] as num?)?.toDouble(),
packageHeight: (json['packageHeight'] ?? json['package_height'] as num?)?.toDouble(),
packageDimensionUom: json['packageDimensionUom'] ?? json['package_dimension_uom'] ?? 'cm',
manufacturerCode: json['manufacturerCode'] ?? json['manufacturer_code'],
material: json['material'],
brandName: json['brandName'] ?? json['brand_name'],
countryOfOrigin: json['countryOfOrigin'] ?? json['country_of_origin'],
weightInG: (json['weightInG'] ?? json['weight_in_g'] as num?)?.toDouble(),
packageWeightInG: (json['packageWeightInG'] ?? json['package_weight_in_g'] as num?)?.toDouble(),
volumetricWeightInKg: (json['volumetricWeightInKg'] ?? json['volumetric_weight_in_kg'] as num?)?.toDouble(),
priceCalcRule:
json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
autoCalculatePrice:
@@ -116,6 +166,21 @@ class Product {
'dimensions': dimensions,
'color': color,
'size': size,
'itemLength': itemLength,
'itemWidth': itemWidth,
'itemHeight': itemHeight,
'dimensionUom': dimensionUom,
'packageLength': packageLength,
'packageWidth': packageWidth,
'packageHeight': packageHeight,
'packageDimensionUom': packageDimensionUom,
'manufacturerCode': manufacturerCode,
'material': material,
'brandName': brandName,
'countryOfOrigin': countryOfOrigin,
'weightInG': weightInG,
'packageWeightInG': packageWeightInG,
'volumetricWeightInKg': volumetricWeightInKg,
'priceCalcRule': priceCalcRule,
'autoCalculatePrice': autoCalculatePrice,
'purityFactor': purityFactor,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.dart';
import '../../../core/theme/nature_colors.dart';
import '../providers/uoms_provider.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../business/providers/business_provider.dart';
import '../../../core/theme/app_theme.dart';
@@ -307,6 +307,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'pcs';
String _defaultLengthUom = 'cm';
final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'},
@@ -325,6 +326,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_baseUnit = isJewellery ? 'g' : 'pcs';
_commodityCode = isJewellery ? 'XAU' : 'NONE';
_defaultLengthUom = 'cm';
if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!;
@@ -343,8 +345,9 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
_makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit ?? (isJewellery ? 'g' : 'pcs');
_baseUnit = c.baseUnit;
if (_baseUnit == 'gm') _baseUnit = 'g';
_defaultLengthUom = c.defaultLengthUom;
}
} else if (widget.parent != null) {
_selectedParentId = widget.parent!.id;
@@ -380,6 +383,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
defaultMakingCharge: _isLeaf && isJewellery ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf && isJewellery ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs',
defaultLengthUom: _isLeaf ? _defaultLengthUom : 'cm',
);
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
@@ -398,6 +402,7 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final uomsState = ref.watch(uomsProvider);
return Container(
height: MediaQuery.of(context).size.height * 0.85,
@@ -623,30 +628,68 @@ class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
const SizedBox(height: 16),
],
DropdownButtonFormField<String>(
value: _baseUnit,
decoration: const InputDecoration(
labelText: 'Base Unit',
uomsState.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(),
),
items: [
if (isJewellery) ...const [
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
] else ...const [
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
DropdownMenuItem(value: 'unit', child: Text('Units (unit)')),
DropdownMenuItem(value: 'box', child: Text('Boxes (box)')),
DropdownMenuItem(value: 'set', child: Text('Sets (set)')),
DropdownMenuItem(value: 'pair', child: Text('Pairs (pair)')),
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 'm', child: Text('Meters (m)')),
DropdownMenuItem(value: 'l', child: Text('Liters (l)')),
error: (err, stack) => DropdownButtonFormField<String>(
value: _baseUnit,
decoration: const InputDecoration(labelText: 'Base Unit'),
items: [
DropdownMenuItem(value: _baseUnit, child: Text(_baseUnit)),
],
],
onChanged: (val) => setState(() => _baseUnit = val!),
onChanged: (val) => setState(() => _baseUnit = val!),
),
data: (uoms) {
final availableValues = uoms.map((u) => u.abbreviation ?? u.name).toList();
final match = uoms.where((u) =>
(u.abbreviation ?? u.name).toLowerCase() == _baseUnit.toLowerCase() ||
u.name.toLowerCase() == _baseUnit.toLowerCase()
).firstOrNull;
final effectiveValue = match != null
? (match.abbreviation ?? match.name)
: (availableValues.contains(_baseUnit) ? _baseUnit : (availableValues.isNotEmpty ? availableValues.first : null));
return DropdownButtonFormField<String>(
value: effectiveValue,
decoration: const InputDecoration(
labelText: 'Base Unit',
prefixIcon: Icon(LucideIcons.scale),
),
isExpanded: true,
items: uoms.map((u) {
final val = u.abbreviation ?? u.name;
return DropdownMenuItem<String>(
value: val,
child: Text(u.displayName),
);
}).toList(),
onChanged: (val) {
if (val != null) {
setState(() => _baseUnit = val);
}
},
);
},
),
if (!isJewellery) ...[
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _defaultLengthUom,
decoration: const InputDecoration(
labelText: 'Default Length UoM',
prefixIcon: Icon(LucideIcons.ruler),
),
items: const [
DropdownMenuItem(value: 'cm', child: Text('Centimeters (cm)')),
DropdownMenuItem(value: 'in', child: Text('Inches (in)')),
DropdownMenuItem(value: 'mm', child: Text('Millimeters (mm)')),
DropdownMenuItem(value: 'm', child: Text('Meters (m)')),
],
onChanged: (val) => setState(() => _defaultLengthUom = val ?? 'cm'),
),
],
],
const SizedBox(height: 40),
],

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:kifi_app/core/widgets/responsive_layout.dart';
import 'package:kifi_app/features/business/providers/business_provider.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/providers/uoms_provider.dart';
import 'package:kifi_app/features/transactions/presentation/widgets/attachment_gallery_screen.dart';
class ProductDetailScreen extends ConsumerWidget {
final Product product;
@@ -70,79 +75,333 @@ class ProductDetailScreen extends ConsumerWidget {
}
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final categories = ref.watch(productCategoriesProvider).value ?? [];
final category = p.categoryId != null
? categories.where((c) => c.id == p.categoryId).firstOrNull
: null;
final uoms = ref.watch(uomsProvider).value ?? [];
final uom = p.uomId != null
? uoms.where((u) => u.id == p.uomId).firstOrNull
: null;
final hasSpecs = (p.brandName != null && p.brandName!.isNotEmpty) ||
(p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty) ||
(p.material != null && p.material!.isNotEmpty) ||
(p.color != null && p.color!.isNotEmpty) ||
(p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty);
final hasDimensions = p.itemLength != null ||
p.itemWidth != null ||
p.itemHeight != null ||
p.weightInG != null;
final hasPackaging = p.packageLength != null ||
p.packageWidth != null ||
p.packageHeight != null ||
p.packageWeightInG != null ||
p.volumetricWeightInKg != null;
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Header
if (p.imageIds.isNotEmpty)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final token = snapshot.data;
if (token == null) {
return const Icon(
Icons.image,
size: 50,
color: Colors.grey,
);
}
return Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 50, color: Colors.grey),
);
},
),
),
const SizedBox(height: 24),
child: MaxContentWidth(
maxWidth: 900,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Images Header & Gallery
if (p.imageIds.isNotEmpty)
_buildImagesSection(context, p),
if (p.imageIds.isNotEmpty)
const SizedBox(height: 20),
// Product Details Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Product Details",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const Divider(),
_buildDetailRow("SKU / Barcode", p.sku ?? "-"),
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
_buildDetailRow(
"GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
),
if (p.makingCharges != null && p.makingCharges! > 0)
_buildDetailRow(
"Making Charges",
p.makingChargesType == 'PERCENTAGE'
? "${p.makingCharges}%"
: (p.makingChargesType == 'PER_PIECE'
? "${p.makingCharges!.toStringAsFixed(2)} / pc"
: "${p.makingCharges!.toStringAsFixed(2)} / g"),
// Product Details Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.package, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Product Details",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
],
const Divider(),
_buildDetailRow("SKU / Barcode", p.sku ?? "-"),
if (category != null)
_buildDetailRow("Category", category.name),
if (uom != null)
_buildDetailRow("Unit of Measure", uom.displayName),
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
_buildDetailRow(
"GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
),
if (isJewellery && p.makingCharges != null && p.makingCharges! > 0)
_buildDetailRow(
"Making Charges",
p.makingChargesType == 'PERCENTAGE'
? "${p.makingCharges}%"
: (p.makingChargesType == 'PER_PIECE'
? "${p.makingCharges!.toStringAsFixed(2)} / pc"
: "${p.makingCharges!.toStringAsFixed(2)} / g"),
),
],
),
),
),
// Non-jewellery Specific Sections
if (!isJewellery) ...[
if (hasSpecs) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.sliders, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Product Specifications",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.brandName != null && p.brandName!.isNotEmpty)
_buildDetailRow("Brand Name", p.brandName!),
if (p.manufacturerCode != null && p.manufacturerCode!.isNotEmpty)
_buildDetailRow("Manufacturer Code", p.manufacturerCode!),
if (p.material != null && p.material!.isNotEmpty)
_buildDetailRow("Material", p.material!),
if (p.color != null && p.color!.isNotEmpty)
_buildDetailRow("Color", p.color!),
if (p.countryOfOrigin != null && p.countryOfOrigin!.isNotEmpty)
_buildDetailRow("Country of Origin", p.countryOfOrigin!),
],
),
),
),
],
if (hasDimensions) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.ruler, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Item Dimensions & Weight",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.itemLength != null || p.itemWidth != null || p.itemHeight != null)
_buildDetailRow(
"Dimensions (L × W × H)",
"${p.itemLength ?? '-'} × ${p.itemWidth ?? '-'} × ${p.itemHeight ?? '-'} ${p.dimensionUom ?? 'cm'}",
),
if (p.weightInG != null)
_buildDetailRow("Item Weight", "${p.weightInG} g"),
],
),
),
),
],
if (hasPackaging) ...[
const SizedBox(height: 16),
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.box, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text(
"Packaging Dimensions & Weight",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Divider(),
if (p.packageLength != null || p.packageWidth != null || p.packageHeight != null)
_buildDetailRow(
"Package (L × W × H)",
"${p.packageLength ?? '-'} × ${p.packageWidth ?? '-'} × ${p.packageHeight ?? '-'} ${p.packageDimensionUom ?? 'cm'}",
),
if (p.packageWeightInG != null)
_buildDetailRow("Package Weight", "${p.packageWeightInG} g"),
if (p.volumetricWeightInKg != null)
_buildDetailRow(
"Volumetric Weight",
"${p.volumetricWeightInKg} kg",
highlight: true,
),
],
),
),
),
],
],
],
),
),
);
}
Widget _buildImagesSection(BuildContext context, Product p) {
return FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
final token = snapshot.data;
if (token == null) {
return const SizedBox.shrink();
}
final allImages = p.imageIds.map<ImageProvider>((imageId) {
return NetworkImage(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
headers: {'Authorization': 'Bearer $token'},
);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: 0,
),
),
);
},
child: Container(
height: 220,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 50, color: Colors.grey),
),
),
),
if (p.imageIds.length > 1) ...[
const SizedBox(height: 12),
SizedBox(
height: 64,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: p.imageIds.length,
separatorBuilder: (context, index) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final imageId = p.imageIds[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: index,
),
),
);
},
child: Container(
width: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/$imageId/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 24, color: Colors.grey),
),
),
);
},
),
),
],
],
);
},
);
}
Widget _buildDetailRow(String label, String value, {bool highlight = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: highlight ? const Color(0xFF4F46E5) : Colors.black87,
),
),
),
@@ -150,17 +409,5 @@ class ProductDetailScreen extends ConsumerWidget {
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
}

View File

@@ -10,6 +10,10 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
import 'package:kifi_app/features/sales/presentation/invoice_details_screen.dart';
import 'package:kifi_app/features/sales/providers/invoices_provider.dart';
import 'package:kifi_app/features/sales/domain/invoice.dart';
import 'package:kifi_app/features/business/providers/business_provider.dart';
import 'package:kifi_app/core/utils/purity_utils.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
@@ -90,6 +94,10 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
displayItems = _items!;
}
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider);
@@ -97,20 +105,24 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
(c) => c.id == widget.product.categoryId,
).firstOrNull;
final bool isCommodity = isJewellery && category?.commodityCode != null && category!.commodityCode!.isNotEmpty;
final String unit = category?.baseUnit ?? 'g';
final double purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: widget.product.purityFactor);
double commodityRate = 0.0;
if (category?.commodityCode != null && commodityRatesState.value != null) {
if (isCommodity && commodityRatesState.value != null && category.commodityCode != null) {
final match = commodityRatesState.value!.where(
(r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()
(r) => r.commodityCode.toUpperCase() == category.commodityCode!.toUpperCase()
).firstOrNull;
if (match != null) {
commodityRate = match.rate;
}
}
final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0);
final double currentRate = isCommodity
? (commodityRate > 0 ? (commodityRate * purityFactor) : (category.dailyRate ?? 0.0))
: 0.0;
return Column(
children: [
@@ -147,6 +159,31 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final isSold = item.status == 'SOLD';
final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? [];
final invoicesState = ref.watch(invoicesProvider);
final invoices = invoicesState.value ?? [];
// Resolve sales invoice for sold item
Invoice? matchingSaleInvoice;
if (isSold) {
if (item.salesRef != null && item.salesRef!.isNotEmpty) {
matchingSaleInvoice = invoices.where((i) => i.invoiceNumber == item.salesRef).firstOrNull;
}
matchingSaleInvoice ??= invoices.where((i) => i.items.any((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
)).firstOrNull;
}
double effectiveSaleRate = (item.saleRate != null && item.saleRate! > 0) ? item.saleRate! : 0.0;
if (isSold && effectiveSaleRate == 0.0 && matchingSaleInvoice != null) {
final matchItem = matchingSaleInvoice.items.where((invIt) =>
(invIt.inventoryItemId != null && invIt.inventoryItemId == item.id) ||
(item.huid != null && item.huid!.isNotEmpty && invIt.huid == item.huid)
).firstOrNull;
if (matchItem != null && matchItem.unitPrice > 0) {
effectiveSaleRate = matchItem.unitPrice;
}
}
double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight!
@@ -202,10 +239,13 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
}
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final double displayedSellingRate = effectiveSaleRate > 0 ? effectiveSaleRate : (isCommodity ? currentRate : purchaseRate);
final currentPrice = weight * (isSold ? displayedSellingRate : currentRate);
final gainLoss = isSold ? ((weight * displayedSellingRate) - purchasePrice) : (currentPrice - purchasePrice);
final isGain = gainLoss >= 0;
final String? salesInvoiceNumber = matchingSaleInvoice?.invoiceNumber ?? item.salesRef;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
@@ -325,46 +365,91 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
if (isSold) ...[
if (salesInvoiceNumber != null && salesInvoiceNumber.isNotEmpty)
InkWell(
onTap: () async {
try {
var inv = matchingSaleInvoice;
if (inv == null) {
var invs = ref.read(invoicesProvider).value;
if (invs == null || invs.isEmpty) {
invs = await ref.read(invoicesProvider.future);
}
inv = invs?.where((i) => i.invoiceNumber == salesInvoiceNumber).firstOrNull;
}
if (inv == null) {
throw Exception('Sales invoice $salesInvoiceNumber not found');
}
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailsScreen(invoice: inv!),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open sales invoice: $e')),
);
}
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
},
child: Text(
'INV: $salesInvoiceNumber',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
] else ...[
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
if (isJewellery && item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
padding: const EdgeInsets.only(top: 2.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
@@ -399,94 +484,175 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
color: isSold ? Colors.grey.shade600 : null,
),
),
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Text(
'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
if (isCommodity) ...[
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Text(
'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
),
),
],
],
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(isSold ? 'Valuation' : 'Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
if (isCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
const SizedBox(width: 8),
Text(
'${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${displayedSellingRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(isSold ? 'Valuation' : 'Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
] else ...[
// Non-Commodity / E-Commerce Products
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
if (isSold && effectiveSaleRate > 0) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sold Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${effectiveSaleRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Sold Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${(weight * effectiveSaleRate).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
],
),
const SizedBox(height: 16),
Builder(
builder: (context) {
final soldAmt = weight * effectiveSaleRate;
final realizedMargin = soldAmt - purchasePrice;
final isProfit = realizedMargin >= 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isProfit ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isProfit ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isProfit ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'Realized ${isProfit ? 'Profit' : 'Loss'}: ₹${realizedMargin.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isProfit ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
);
},
),
],
],
],
),
),

View File

@@ -16,6 +16,7 @@ class ProductCategory {
final double? defaultMakingCharge;
final String? makingChargeType;
final String baseUnit;
final String defaultLengthUom;
final bool isActive;
final int sortOrder;
final double? dailyRate;
@@ -34,6 +35,7 @@ class ProductCategory {
this.defaultMakingCharge,
this.makingChargeType,
this.baseUnit = 'pcs',
this.defaultLengthUom = 'cm',
this.isActive = true,
this.sortOrder = 0,
this.dailyRate,
@@ -54,6 +56,7 @@ class ProductCategory {
defaultMakingCharge: (json['defaultMakingCharge'] ?? json['default_making_charge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'] ?? json['making_charge_type'],
baseUnit: json['baseUnit'] ?? json['base_unit'] ?? 'pcs',
defaultLengthUom: json['defaultLengthUom'] ?? json['default_length_uom'] ?? 'cm',
isActive: json['isActive'] ?? json['is_active'] ?? true,
sortOrder: json['sortOrder'] ?? json['sort_order'] ?? 0,
dailyRate: (json['dailyRate'] ?? json['daily_rate'] as num?)?.toDouble(),
@@ -75,6 +78,7 @@ class ProductCategory {
'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType,
'baseUnit': baseUnit,
'defaultLengthUom': defaultLengthUom,
'isActive': isActive,
'sortOrder': sortOrder,
};

View File

@@ -57,11 +57,20 @@ class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
// If empty in database, seed standard units of measure
try {
final defaults = [
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Units', 'abbreviation': 'unit'},
{'name': 'Boxes', 'abbreviation': 'box'},
{'name': 'Sets', 'abbreviation': 'set'},
{'name': 'Pairs', 'abbreviation': 'pair'},
{'name': 'Packs', 'abbreviation': 'pk'},
{'name': 'Grams', 'abbreviation': 'g'},
{'name': 'Kilograms', 'abbreviation': 'kg'},
{'name': 'Pieces', 'abbreviation': 'pcs'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Milligrams', 'abbreviation': 'mg'},
{'name': 'Carats', 'abbreviation': 'ct'},
{'name': 'Meters', 'abbreviation': 'm'},
{'name': 'Liters', 'abbreviation': 'l'},
{'name': 'Milliliters', 'abbreviation': 'ml'},
{'name': 'Dozens', 'abbreviation': 'doz'},
];
for (final def in defaults) {
await DioClient().dio.post('/inventory/uom', data: def);

View File

@@ -1175,6 +1175,9 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
}) {
final sku = product.sku;
final qtyCtrl = _getQtyController(product.id ?? 0);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -1228,28 +1231,31 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
_buildPOSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty)
_buildPOSheetBadge('SKU: $sku', Colors.blue),
_buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal),
if (isJewellery)
_buildPOSheetBadge('Purity: ${formatPurity(resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor))}', Colors.teal),
],
),
],
),
),
const SizedBox(width: 12),
// Small box to enter number of items
SizedBox(
width: 75,
child: TextFormField(
controller: qtyCtrl,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: 'Qty',
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
if (isJewellery) ...[
const SizedBox(width: 12),
// Small box to enter number of items
SizedBox(
width: 75,
child: TextFormField(
controller: qtyCtrl,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: InputDecoration(
labelText: 'Qty',
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
),
],
],
),
const Divider(height: 20),
@@ -1257,7 +1263,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
final qty = isJewellery ? (int.tryParse(qtyCtrl.text.trim()) ?? 1) : 1;
_addItemsFromCard(product, category, qty);
},
style: ElevatedButton.styleFrom(
@@ -1278,7 +1284,7 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> {
}
class _POItemRow extends StatefulWidget {
class _POItemRow extends ConsumerStatefulWidget {
final PurchaseOrderItem item;
final String productName;
final Product? product;
@@ -1300,14 +1306,15 @@ class _POItemRow extends StatefulWidget {
});
@override
State<_POItemRow> createState() => _POItemRowState();
ConsumerState<_POItemRow> createState() => _POItemRowState();
}
class _POItemRowState extends State<_POItemRow> {
class _POItemRowState extends ConsumerState<_POItemRow> {
late TextEditingController _weightCtrl;
late TextEditingController _rateCtrl;
late TextEditingController _huidCtrl;
late TextEditingController _totalCtrl;
bool _isUploadingPhoto = false;
@override
void initState() {
@@ -1339,15 +1346,13 @@ class _POItemRowState extends State<_POItemRow> {
final updated = widget.item.copyWith(
weight: weight,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
unitPrice: rate,
total: total,
huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text,
);
widget.onChanged(updated);
}
bool _isUploadingPhoto = false;
Future<void> _pickPhoto() async {
try {
final picker = ImagePicker();
@@ -1393,6 +1398,10 @@ class _POItemRowState extends State<_POItemRow> {
@override
Widget build(BuildContext context) {
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@@ -1534,18 +1543,19 @@ class _POItemRowState extends State<_POItemRow> {
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
if (isJewellery)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
),
child: Text(
'Purity: ${formatPurity(resolvePurity(categoryPurity: widget.category?.purityFactor, productPurity: widget.product?.purityFactor))}',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.amber.shade900),
),
),
child: Text(
'Purity: ${formatPurity(resolvePurity(categoryPurity: widget.category?.purityFactor, productPurity: widget.product?.purityFactor))}',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.amber.shade900),
),
),
],
),
],
@@ -1558,83 +1568,174 @@ class _POItemRowState extends State<_POItemRow> {
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: PremiumTextField(
controller: _huidCtrl,
labelText: 'HUID',
textCapitalization: TextCapitalization.characters,
onChanged: (_) => _updateItem(),
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: PremiumTextField(
controller: _weightCtrl,
labelText: 'Weight/Pcs',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 600;
if (isWide) {
return Row(
children: [
if (isJewellery) ...[
Expanded(
flex: 2,
child: PremiumTextField(
controller: _huidCtrl,
labelText: 'HUID',
textCapitalization: TextCapitalization.characters,
onChanged: (_) => _updateItem(),
),
),
const SizedBox(width: 8),
],
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _weightCtrl,
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _rateCtrl,
labelText: 'Rate',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: isJewellery ? 2 : 3,
child: PremiumTextField(
controller: _totalCtrl,
labelText: 'Purchase Amt',
keyboardType: TextInputType.number,
readOnly: true,
onChanged: (_) => _updateItem(),
),
),
],
);
}
return Column(
children: [
Row(
children: [
if (isJewellery) ...[
Expanded(
flex: 2,
child: PremiumTextField(
controller: _huidCtrl,
labelText: 'HUID',
textCapitalization: TextCapitalization.characters,
onChanged: (_) => _updateItem(),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: PremiumTextField(
controller: _rateCtrl,
labelText: 'Rate',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: PremiumTextField(
controller: _totalCtrl,
labelText: 'Purchase Amt',
keyboardType: TextInputType.number,
readOnly: true,
onChanged: (_) => _updateItem(),
),
),
],
),
const SizedBox(width: 8),
],
Expanded(
flex: 2,
child: PremiumTextField(
controller: _weightCtrl,
labelText: isJewellery ? 'Weight/Pcs' : 'Weight/Pcs/Length',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text(widget.category?.baseUnit ?? '', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: PremiumTextField(
controller: _rateCtrl,
labelText: 'Rate',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => _updateItem(),
suffixIcon: (widget.category?.baseUnit != null && widget.category!.baseUnit.isNotEmpty)
? Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)),
border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [Text('per ${widget.category?.baseUnit ?? ''}', style: TextStyle(color: Colors.grey[700]))],
),
)
: null,
),
),
const SizedBox(width: 8),
Expanded(
flex: 2,
child: PremiumTextField(
controller: _totalCtrl,
labelText: 'Purchase Amt',
keyboardType: TextInputType.number,
readOnly: true,
onChanged: (_) => _updateItem(),
),
),
],
),
],
);
},
),
const SizedBox(height: 16),
_buildTaxAndTotal(),