Files
Kifi/kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart

3117 lines
137 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'package:dio/dio.dart';
import '../../../core/network/dio_client.dart';
import 'package:intl/intl.dart';
import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart';
import '../../../core/widgets/barcode_scanner_screen.dart';
import '../../inventory/providers/products_provider.dart';
import '../../inventory/providers/product_categories_provider.dart';
import '../../inventory/providers/commodity_rates_provider.dart';
import '../../inventory/providers/inventory_items_provider.dart';
import '../../inventory/providers/uoms_provider.dart';
import '../../inventory/domain/product.dart';
import '../../inventory/domain/inventory_item.dart';
import '../providers/customers_provider.dart';
import '../providers/invoices_provider.dart';
import '../domain/customer.dart';
import '../domain/invoice.dart';
import '../providers/sales_channels_provider.dart';
import '../../business/providers/business_provider.dart';
import '../../business/providers/indian_states_provider.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
import 'widgets/quick_add_customer_sheet.dart';
import '../../../core/utils/purity_utils.dart';
import 'invoice_details_screen.dart';
bool _isCommodityProduct({
ProductCategory? category,
Product? product,
InventoryItem? inventoryItem,
InvoiceItem? invoiceItem,
String? commodityCode,
String? huid,
}) {
final h = huid ?? invoiceItem?.huid ?? inventoryItem?.huid;
if (h != null && h.trim().isNotEmpty) return true;
final code = commodityCode ??
category?.commodityCode ??
invoiceItem?.commodityCode;
if (code != null && code.trim().isNotEmpty && code.trim().toUpperCase() != 'NONE') {
return true;
}
return false;
}
String _resolveProductUnit({
required Product? product,
required ProductCategory? category,
required List<UnitOfMeasure> uoms,
bool isCommodity = false,
}) {
if (isCommodity) {
return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g';
}
if (product?.uomId != null) {
final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull;
if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!;
if (uom != null && uom.name.isNotEmpty) return uom.name;
}
if (category != null && category.baseUnit.isNotEmpty) {
return category.baseUnit;
}
return 'pcs';
}
String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
final base = DioClient().dio.options.baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
}
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
}
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
final Invoice? existingInvoice;
final int? initialCustomerId;
final List<InvoiceItem>? initialItems;
const InvoiceBuilderScreen({
super.key,
this.existingInvoice,
this.initialCustomerId,
this.initialItems,
});
@override
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
}
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
final _formKey = GlobalKey<FormState>();
final _invoiceNumberCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
final _amountPaidCtrl = TextEditingController();
final _emiAmountCtrl = TextEditingController();
final _discountCtrl = TextEditingController(text: '0.0');
final _marketplaceOrderIdCtrl = TextEditingController();
DateTime _invoiceDate = DateTime.now();
DateTime? _dueDate;
int? _selectedCustomerId;
int? _selectedSalesChannelId;
String? _selectedSalesChannelName;
int? _selectedPlaceOfSupplyStateId;
String? _selectedPlaceOfSupplyStateName;
final List<InvoiceItem> _items = [];
bool _isLoading = false;
XFile? _invoiceFile;
String? _invoiceUrl;
String _paymentMethod = 'Cash';
int? _selectedWalletId;
DateTime? _nextPaymentDate;
bool _isAmountPaidEdited = false;
// EMI State
bool _isEmi = false;
String _emiCycle = 'MONTHLY';
DateTime? _emiStartDate;
@override
void initState() {
super.initState();
if (widget.existingInvoice != null) {
final inv = widget.existingInvoice!;
_invoiceNumberCtrl.text = inv.invoiceNumber;
_invoiceDate = inv.issueDate;
_dueDate = inv.dueDate;
_selectedCustomerId = inv.customerId;
_selectedSalesChannelId = inv.salesChannelId;
_selectedSalesChannelName = inv.salesChannel;
_marketplaceOrderIdCtrl.text = inv.marketplaceOrderId ?? '';
_selectedPlaceOfSupplyStateId = inv.placeOfSupplyStateId;
_selectedPlaceOfSupplyStateName = inv.placeOfSupply;
_notesCtrl.text = inv.notes ?? '';
_items.addAll(inv.items);
_invoiceUrl = inv.invoiceUrl;
if (inv.discountTotal > 0) {
_discountCtrl.text = inv.discountTotal.toStringAsFixed(2);
}
_amountPaidCtrl.text = inv.amountPaid > 0 ? inv.amountPaid.toStringAsFixed(2) : '';
_selectedWalletId = inv.paymentWalletId;
_paymentMethod = inv.paymentMethod ?? 'Cash';
_nextPaymentDate = inv.nextPaymentDate;
_isAmountPaidEdited = inv.amountPaid > 0;
_isEmi = inv.isEmi;
_emiCycle = inv.emiCycle ?? 'MONTHLY';
_emiStartDate = inv.emiStartDate;
if (inv.emiAmount != null) {
_emiAmountCtrl.text = inv.emiAmount!.toStringAsFixed(2);
}
} else {
_invoiceNumberCtrl.text = 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}';
_selectedCustomerId = widget.initialCustomerId;
_dueDate = DateTime.now().add(const Duration(days: 30));
_emiStartDate = DateTime.now().add(const Duration(days: 30));
if (widget.initialItems != null) {
_items.addAll(widget.initialItems!);
}
}
}
@override
void dispose() {
_invoiceNumberCtrl.dispose();
_notesCtrl.dispose();
_amountPaidCtrl.dispose();
_emiAmountCtrl.dispose();
_discountCtrl.dispose();
_marketplaceOrderIdCtrl.dispose();
super.dispose();
}
Future<void> _pickInvoiceFile() async {
try {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (picked != null) {
setState(() {
_invoiceFile = picked;
_isLoading = true;
});
final bytes = await picked.readAsBytes();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: picked.name.isNotEmpty ? picked.name : 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg',
),
'type': 'SALES_INVOICE',
});
final uploadResp = await DioClient().dio.post(
'/upload',
data: formData,
);
if (uploadResp.statusCode == 200) {
setState(() {
_invoiceUrl = uploadResp.data['url'];
});
}
}
} catch (e) {
debugPrint('Error uploading sales invoice attachment: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showQuickAddCustomer() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const QuickAddCustomerSheet(),
).then((result) {
if (result != null) {
if (result is Customer && result.id != null) {
setState(() => _selectedCustomerId = result.id);
} else {
ref.refresh(customersProvider);
}
}
});
}
void _openAddItemSheet({int? editIndex}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _AddSalesItemSheet(
initialItem: editIndex != null ? _items[editIndex] : null,
onItemAdded: (item) {
setState(() {
if (editIndex != null) {
_items[editIndex] = item;
} else {
_items.add(item);
}
});
},
),
);
}
Future<void> _scanBarcode() async {
final scannedCode = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
);
if (scannedCode != null && scannedCode.isNotEmpty) {
final inventoryItems = ref.read(inventoryItemsProvider).value ?? [];
final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? [];
final commodityRates = ref.read(commodityRatesProvider).value ?? [];
// Check inventory item by HUID or SKU or Barcode
final invItem = inventoryItems.where((i) =>
(i.huid != null && i.huid!.toUpperCase() == scannedCode.toUpperCase()) ||
(i.sku != null && i.sku!.toUpperCase() == scannedCode.toUpperCase())
).firstOrNull;
Product? matchedProduct;
if (invItem != null) {
matchedProduct = products.where((p) => p.id == invItem.productId).firstOrNull;
} else {
matchedProduct = products.where((p) =>
(p.barcode != null && p.barcode == scannedCode) ||
(p.sku != null && p.sku!.toUpperCase() == scannedCode.toUpperCase())
).firstOrNull;
}
if (matchedProduct != null) {
final cat = categories.where((c) => c.id == matchedProduct!.categoryId).firstOrNull;
double commodityRate = 0.0;
if (cat?.commodityCode != null) {
final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
if (match != null) commodityRate = match.rate;
}
final purity = resolvePurity(categoryPurity: cat?.purityFactor, productPurity: matchedProduct.purityFactor);
final effectiveRate = commodityRate > 0 ? (commodityRate * purity) : (matchedProduct.sellingPrice ?? 0.0);
final weight = invItem?.grossWeight ?? 1.0;
final makingCharge = invItem?.makingCharges ?? matchedProduct.makingCharges ?? cat?.defaultMakingCharge ?? 0.0;
final makingType = invItem?.makingChargeType ?? matchedProduct.makingChargesType ?? cat?.makingChargeType ?? 'PER_GRAM';
final metalAmount = weight * effectiveRate;
double makingAmt = 0.0;
if (makingType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (makingType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
final gstRate = matchedProduct.gstRate ?? (cat?.defaultGst ?? 3.0);
final taxable = metalAmount + makingAmt;
final taxAmount = (taxable * gstRate) / 100.0;
final total = taxable + taxAmount;
final item = InvoiceItem(
productId: matchedProduct.id,
inventoryItemId: invItem?.id,
productName: matchedProduct.name,
categoryName: cat?.name,
commodityCode: cat?.commodityCode,
hsnCode: cat?.defaultHsn ?? matchedProduct.hsnCode,
sku: invItem?.sku ?? matchedProduct.sku,
huid: invItem?.huid,
description: matchedProduct.name,
quantity: 1.0,
weight: weight,
unitPrice: effectiveRate,
makingCharge: makingCharge,
makingChargesType: makingType,
taxRate: gstRate,
total: total,
);
setState(() => _items.add(item));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${matchedProduct.name} via barcode scan!'), backgroundColor: Colors.green),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No item found with barcode/HUID: $scannedCode'), backgroundColor: Colors.orange),
);
}
}
}
}
Future<void> _saveInvoice() async {
if (!_formKey.currentState!.validate()) return;
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please add at least one line item')),
);
return;
}
setState(() => _isLoading = true);
try {
String? finalInvoiceUrl = _invoiceUrl;
if (_invoiceFile != null && finalInvoiceUrl == null) {
final bytes = await _invoiceFile!.readAsBytes();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
),
'type': 'SALES_INVOICE',
});
final uploadResp = await DioClient().dio.post(
'/upload',
data: formData,
);
if (uploadResp.statusCode == 200) {
finalInvoiceUrl = uploadResp.data['url'];
}
}
final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? [];
final businessStateId = ref.read(businessProfileProvider).value?.stateId;
final customers = ref.read(customersProvider).value ?? [];
final customer = _selectedCustomerId == null
? null
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId;
double subtotal = 0;
double totalCgst = 0;
double totalSgst = 0;
double totalIgst = 0;
double totalTax = 0;
double totalMaking = 0;
double totalDiscount = 0;
double grandTotal = 0;
final List<InvoiceItem> processedItems = [];
for (var item in _items) {
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0);
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final baseItemAmt = qtyOrWeight * item.unitPrice;
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
double makingChargeAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = baseItemAmt * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge;
} else {
makingChargeAmt = qtyOrWeight * item.makingCharge;
}
}
final taxableAmount = baseItemAmt + makingChargeAmt + item.otherCharges - item.discount;
final taxAmount = (taxableAmount * gstRate) / 100.0;
double cgst = 0;
double sgst = 0;
double igst = 0;
if (isSameState) {
cgst = taxAmount / 2.0;
sgst = taxAmount / 2.0;
totalCgst += cgst;
totalSgst += sgst;
} else {
igst = taxAmount;
totalIgst += igst;
}
final lineTotal = taxableAmount + taxAmount;
subtotal += baseItemAmt;
totalMaking += makingChargeAmt;
totalTax += taxAmount;
processedItems.add(item.copyWith(
quantity: item.quantity > 0 ? item.quantity : qtyOrWeight,
weight: item.weight,
taxRate: gstRate,
cgst: cgst,
sgst: sgst,
igst: igst,
total: lineTotal,
));
}
final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0;
totalDiscount = double.parse(invoiceDiscount.toStringAsFixed(2));
final rawGrandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax;
grandTotal = double.parse(rawGrandTotal.toStringAsFixed(2));
final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
final validAmountPaid = double.parse((amountPaid > grandTotal ? grandTotal : amountPaid).toStringAsFixed(2));
final balanceDue = double.parse((grandTotal - validAmountPaid).clamp(0.0, double.infinity).toStringAsFixed(2));
final isFullyPaid = balanceDue.abs() < 0.01 || validAmountPaid >= grandTotal - 0.01;
final determinedStatus = isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : 'DRAFT');
final invoice = Invoice(
id: widget.existingInvoice?.id,
customerId: _selectedCustomerId,
invoiceNumber: _invoiceNumberCtrl.text.trim(),
issueDate: _invoiceDate,
dueDate: _dueDate,
subtotal: double.parse(subtotal.toStringAsFixed(2)),
taxTotal: double.parse(totalTax.toStringAsFixed(2)),
cgstTotal: double.parse(totalCgst.toStringAsFixed(2)),
sgstTotal: double.parse(totalSgst.toStringAsFixed(2)),
igstTotal: double.parse(totalIgst.toStringAsFixed(2)),
discountTotal: totalDiscount,
totalAmount: grandTotal,
amountPaid: validAmountPaid,
paymentMethod: validAmountPaid > 0 ? _paymentMethod : null,
paymentWalletId: validAmountPaid > 0
? (_selectedWalletId ??
ref.read(walletProvider).value?.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').firstOrNull?.id)
: null,
nextPaymentDate: balanceDue > 0.01 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
status: widget.existingInvoice != null
? (isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : widget.existingInvoice!.status))
: determinedStatus,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
invoiceUrl: finalInvoiceUrl,
isEmi: _isEmi,
emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.tryParse(_emiAmountCtrl.text) : null,
emiCycle: _isEmi ? _emiCycle : null,
emiStartDate: _isEmi ? (_emiStartDate ?? DateTime.now().add(const Duration(days: 30))) : null,
salesChannelId: _selectedSalesChannelId,
salesChannel: _selectedSalesChannelName,
marketplaceOrderId: _marketplaceOrderIdCtrl.text.trim().isNotEmpty ? _marketplaceOrderIdCtrl.text.trim() : null,
placeOfSupplyStateId: effectiveSupplyStateId,
placeOfSupply: _selectedPlaceOfSupplyStateName ?? ref.read(indianStatesProvider).value?.where((s) => s.id == effectiveSupplyStateId).firstOrNull?.name,
items: processedItems,
);
Invoice? savedInvoice;
if (widget.existingInvoice == null) {
savedInvoice = await ref.read(invoicesProvider.notifier).createInvoice(invoice);
} else {
final resp = await DioClient().dio.put(
'/invoices/${widget.existingInvoice!.id}',
data: invoice.toJson(),
);
if (resp.statusCode == 200) {
savedInvoice = Invoice.fromJson(resp.data);
ref.refresh(invoicesProvider);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sales Invoice saved successfully!'), backgroundColor: Colors.green),
);
if (savedInvoice != null) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => InvoiceDetailsScreen(invoice: savedInvoice!),
),
);
} else {
Navigator.pop(context);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error saving invoice: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final customersState = ref.watch(customersProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: Text(
widget.existingInvoice == null
? 'New Sales Invoice'
: 'Edit Invoice ${_invoiceNumberCtrl.text}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
actions: [
IconButton(
icon: const Icon(LucideIcons.qrCode),
tooltip: 'Scan Barcode/HUID',
onPressed: _scanBarcode,
),
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton.icon(
onPressed: _saveInvoice,
icon: const Icon(LucideIcons.check, size: 18),
label: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
behavior: HitTestBehavior.translucent,
child: Form(
key: _formKey,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16.0),
children: [
// E-Commerce / Sales Channels Selector (If enabled)
Builder(
builder: (context) {
final featureState = ref.watch(businessFeatureProvider);
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final isChannelsEnabled = featureState.value?.enableEcommerceChannels ?? (!isJewellery);
if (!isChannelsEnabled) return const SizedBox.shrink();
final salesChannelsState = ref.watch(salesChannelsProvider);
return salesChannelsState.when(
data: (channels) {
final activeChannels = channels.where((c) => c.isActive).toList();
if (activeChannels.isEmpty) return const SizedBox.shrink();
final currentSelectedId = _selectedSalesChannelId;
final selectedChannel = activeChannels.where((c) =>
(currentSelectedId != null && c.id == currentSelectedId) ||
(currentSelectedId == null && c.name.toUpperCase() == (_selectedSalesChannelName ?? '').toUpperCase())
).firstOrNull ?? activeChannels.where((c) => c.code == 'DIRECT' || c.name.toLowerCase().contains('direct')).firstOrNull ?? activeChannels.first;
final isMarketplace = (selectedChannel.code != 'DIRECT' &&
!selectedChannel.name.toLowerCase().contains('direct') &&
!selectedChannel.name.toLowerCase().contains('store'));
return Container(
margin: const EdgeInsets.only(bottom: 16.0),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.shoppingBag, size: 16, color: Colors.blue.shade700),
const SizedBox(width: 6),
const Text(
'Sales Channel / Marketplace',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: activeChannels.map((channel) {
final isSelected = (currentSelectedId != null && currentSelectedId == channel.id) ||
(currentSelectedId == null && selectedChannel.id == channel.id);
IconData iconData = LucideIcons.store;
if (channel.code == 'AMAZON' || channel.name.toLowerCase().contains('amazon')) {
iconData = LucideIcons.shoppingCart;
} else if (channel.code == 'FLIPKART' || channel.name.toLowerCase().contains('flipkart')) {
iconData = LucideIcons.package;
} else if (channel.code == 'SHOPIFY' || channel.name.toLowerCase().contains('shopify')) {
iconData = LucideIcons.globe;
} else if (channel.code == 'MEESHO' || channel.name.toLowerCase().contains('meesho')) {
iconData = LucideIcons.tag;
} else if (channel.code == 'QUICK_COMMERCE' || channel.name.toLowerCase().contains('quick')) {
iconData = LucideIcons.zap;
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
selected: isSelected,
avatar: Icon(
iconData,
size: 15,
color: isSelected ? Colors.white : Colors.grey.shade700,
),
label: Text(
channel.name,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : Colors.grey.shade800,
),
),
selectedColor: Colors.blue.shade700,
onSelected: (selected) {
if (selected) {
setState(() {
_selectedSalesChannelId = channel.id;
_selectedSalesChannelName = channel.name;
// If customer is not yet selected, attempt matching default channel customer (e.g. Amazon)
if (_selectedCustomerId == null) {
final customersList = ref.read(customersProvider).value ?? [];
final match = customersList.where((c) =>
c.name.toLowerCase().contains(channel.name.toLowerCase()) ||
(channel.code != null && c.name.toLowerCase().contains(channel.code!.toLowerCase()))
).firstOrNull;
if (match != null) {
_selectedCustomerId = match.id;
if (match.stateId != null) {
_selectedPlaceOfSupplyStateId = match.stateId;
}
}
}
});
}
},
),
);
}).toList(),
),
),
if (isMarketplace) ...[
const SizedBox(height: 12),
PremiumTextField(
controller: _marketplaceOrderIdCtrl,
labelText: '${selectedChannel.name} Order ID (e.g. 408-1234567-8901234)',
prefixIcon: const Icon(LucideIcons.hash, size: 16),
),
],
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (err, stack) => const SizedBox.shrink(),
);
},
),
// Customer Selection Row + Quick Add
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: customersState.when(
data: (customers) => SmartSearchDropdown<Customer>(
labelText: 'Customer *',
hintText: 'Search Customer by Name / Phone / GSTIN / City',
value: customers.where((c) => c.id == _selectedCustomerId).firstOrNull,
items: customers,
itemAsString: (c) => c.name,
filterFn: (c, query) {
final q = query.toLowerCase();
return c.name.toLowerCase().contains(q) ||
(c.phone != null && c.phone!.contains(q)) ||
(c.email != null && c.email!.toLowerCase().contains(q)) ||
(c.gstin != null && c.gstin!.toLowerCase().contains(q)) ||
(c.address != null && c.address!.toLowerCase().contains(q));
},
itemBuilder: (context, c) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
backgroundColor: Colors.blue.withValues(alpha: 0.15),
child: Text(
c.name.isNotEmpty ? c.name[0].toUpperCase() : 'C',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue, fontSize: 14),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 3),
Wrap(
spacing: 6,
runSpacing: 3,
children: [
if (c.phone != null && c.phone!.isNotEmpty)
_buildSmallBadge(c.phone!, Colors.teal),
if (c.address != null && c.address!.isNotEmpty)
_buildSmallBadge(c.address!, Colors.purple),
if (c.gstin != null && c.gstin!.isNotEmpty)
_buildSmallBadge('GST: ${c.gstin}', Colors.indigo),
],
),
],
),
),
],
),
);
},
onChanged: (val) {
setState(() {
_selectedCustomerId = val?.id;
if (val?.stateId != null) {
_selectedPlaceOfSupplyStateId = val!.stateId;
}
});
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error loading customers: $e'),
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: IconButton.filledTonal(
onPressed: _showQuickAddCustomer,
icon: const Icon(LucideIcons.userPlus, size: 20),
tooltip: 'Quick Add Customer',
),
),
],
),
const SizedBox(height: 12),
// Place of Supply / Destination Delivery State
Consumer(
builder: (context, ref, child) {
final statesState = ref.watch(indianStatesProvider);
final businessStateId = ref.watch(businessProfileProvider).value?.stateId;
final customers = ref.watch(customersProvider).value ?? [];
final customer = _selectedCustomerId == null
? null
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
final effectiveStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
final isSameState = businessStateId != null && effectiveStateId != null && businessStateId == effectiveStateId;
return statesState.when(
data: (states) {
final selectedState = states.where((s) => s.id == effectiveStateId).firstOrNull;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSameState
? Colors.green.withValues(alpha: 0.05)
: Colors.purple.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSameState
? Colors.green.withValues(alpha: 0.25)
: Colors.purple.withValues(alpha: 0.25),
),
),
child: Row(
children: [
Icon(
LucideIcons.mapPin,
size: 18,
color: isSameState ? Colors.green.shade700 : Colors.purple.shade700,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Place of Supply (Delivery State)',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.grey.shade600,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: isSameState ? Colors.green.shade100 : Colors.purple.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
isSameState ? 'Intra-state (CGST + SGST)' : 'Inter-state (IGST)',
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: isSameState ? Colors.green.shade900 : Colors.purple.shade900,
),
),
),
],
),
const SizedBox(height: 2),
DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: selectedState?.id,
isDense: true,
isExpanded: true,
hint: const Text('Select State of Supply', style: TextStyle(fontSize: 13)),
items: states.map((s) {
final isHome = s.id == businessStateId;
return DropdownMenuItem<int>(
value: s.id,
child: Text(
'${s.name} (${s.gstCode})${isHome ? " - Home State" : ""}',
style: TextStyle(
fontSize: 13,
fontWeight: isHome ? FontWeight.bold : FontWeight.normal,
),
),
);
}).toList(),
onChanged: (val) {
if (val != null) {
final picked = states.where((s) => s.id == val).firstOrNull;
setState(() {
_selectedPlaceOfSupplyStateId = val;
_selectedPlaceOfSupplyStateName = picked?.name;
});
}
},
),
),
],
),
),
],
),
);
},
loading: () => const SizedBox.shrink(),
error: (err, stack) => const SizedBox.shrink(),
);
},
),
const SizedBox(height: 16),
PremiumTextField(
controller: _invoiceNumberCtrl,
labelText: 'Invoice Number *',
prefixIcon: const Icon(LucideIcons.fileText),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _invoiceDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => _invoiceDate = picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Invoice Date',
prefixIcon: const Icon(LucideIcons.calendar),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(
DateFormat('dd MMM yyyy').format(_invoiceDate),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
),
),
const SizedBox(height: 24),
// Items Section Header with Barcode + Add Item
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Invoice Items (${_items.length})',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _scanBarcode,
icon: const Icon(LucideIcons.qrCode, size: 18),
tooltip: 'Scan Barcode',
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
onPressed: () => _openAddItemSheet(),
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Item'),
),
],
),
],
),
const SizedBox(height: 12),
// Items List
if (_items.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(LucideIcons.packageOpen, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'No items added yet',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey.shade600),
),
const SizedBox(height: 4),
Text(
'Tap "Add Item" or scan a barcode/SKU to add sales items',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
)
else
..._items.asMap().entries.map((entry) {
final idx = entry.key;
final item = entry.value;
return _buildSalesItemCard(item, idx, isDark);
}),
const SizedBox(height: 20),
// Invoice Discount Input Field before Summary
PremiumTextField(
controller: _discountCtrl,
labelText: 'Invoice Discount (₹)',
prefixIcon: const Icon(LucideIcons.tag, color: Colors.red),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 20),
// Grand Total & Tax Summary Box
_buildTotalsSummaryCard(isDark),
const SizedBox(height: 24),
// Payment / Settlement Details (Full Partial Payment & EMI Support)
_buildPaymentSection(isDark),
const SizedBox(height: 24),
// Notes & Remarks
PremiumTextField(
controller: _notesCtrl,
labelText: 'Notes & Terms',
maxLines: 3,
prefixIcon: const Icon(LucideIcons.alignLeft),
),
const SizedBox(height: 24),
// Attachments
Text(
'Attached Invoice / Documents',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (_invoiceFile != null)
ListTile(
leading: const Icon(LucideIcons.image, color: Colors.blue),
title: Text(_invoiceFile!.name),
subtitle: const Text('Tap to view image', style: TextStyle(fontSize: 12, color: Colors.blue)),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [kIsWeb ? NetworkImage(_invoiceFile!.path) : FileImage(File(_invoiceFile!.path))],
initialIndex: 0,
),
),
);
},
trailing: IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _invoiceFile = null),
),
)
else if (_invoiceUrl != null && _invoiceUrl!.isNotEmpty)
ListTile(
leading: const Icon(LucideIcons.image, color: Colors.blue),
title: const Text('View Attached Invoice'),
subtitle: const Text('Tap to view full image', style: TextStyle(fontSize: 12, color: Colors.blue)),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [NetworkImage(_resolveImageUrl(_invoiceUrl!))],
initialIndex: 0,
),
),
);
},
trailing: IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _invoiceUrl = null),
),
)
else
OutlinedButton.icon(
onPressed: _pickInvoiceFile,
icon: const Icon(LucideIcons.paperclip),
label: const Text('Attach Sales Bill / Slip'),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
const SizedBox(height: 40),
],
),
),
),
);
}
Widget _buildSalesItemCard(InvoiceItem item, int index, bool isDark) {
final uoms = ref.watch(uomsProvider).value ?? [];
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final isItemCommodity = _isCommodityProduct(
category: category,
product: product,
invoiceItem: item,
);
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final baseAmount = qtyOrWeight * item.unitPrice;
double makingChargeAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = baseAmount * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge;
} else {
makingChargeAmt = qtyOrWeight * item.makingCharge;
}
}
final taxableAmount = baseAmount + makingChargeAmt + item.otherCharges - item.discount;
final taxAmount = (taxableAmount * item.taxRate) / 100.0;
final calculatedTotal = item.total > 0 ? item.total : (taxableAmount + taxAmount);
final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.photoUrl != null && item.photoUrl!.isNotEmpty)
Container(
width: 48,
height: 48,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: NetworkImage(_resolveImageUrl(item.photoUrl!)),
fit: BoxFit.cover,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName ?? item.description ?? 'Item #${index + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (item.categoryName != null && item.categoryName!.isNotEmpty)
_buildSmallBadge(item.categoryName!, const Color(0xFFD4AF37)),
if (item.sku != null && item.sku!.isNotEmpty)
_buildSmallBadge('SKU: ${item.sku}', Colors.blue),
if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty)
_buildSmallBadge('HUID: ${item.huid}', Colors.purple),
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
_buildSmallBadge('HSN: ${item.hsnCode}', Colors.grey),
if (!isItemCommodity)
_buildSmallBadge('Unit: $unit', Colors.teal),
],
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.edit2, size: 18, color: Colors.blue),
tooltip: 'Edit Item',
onPressed: () => _openAddItemSheet(editIndex: index),
),
IconButton(
icon: const Icon(LucideIcons.trash2, size: 18, color: Colors.red),
tooltip: 'Remove Item',
onPressed: () {
setState(() => _items.removeAt(index));
},
),
],
),
const Divider(height: 20),
if (isItemCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Metal: ${qtyOrWeight.toStringAsFixed(3)}$unit ×${item.unitPrice.toStringAsFixed(2)}',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
Text(
'${baseAmount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
],
),
if (makingChargeAmt > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Making Charges (${item.makingChargesType == 'PERCENTAGE' ? '${item.makingCharge}%' : (item.makingChargesType == 'PER_PIECE' ? '${item.makingCharge}/pc' : '${item.makingCharge}/g')}):',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
Text(
'${makingChargeAmt.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.indigo),
),
],
),
],
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Rate: ₹${item.unitPrice.toStringAsFixed(2)} / $unit • Qty: ${qtyOrWeight.toStringAsFixed(qtyOrWeight.truncateToDouble() == qtyOrWeight ? 0 : 2)} $unit',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
),
Text(
'${baseAmount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
],
),
],
if (item.discount > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Discount:', style: TextStyle(fontSize: 12, color: Colors.red)),
Text('- ₹${item.discount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.red)),
],
),
],
if (item.taxRate > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'GST (${item.taxRate.toStringAsFixed(1)}%):',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
Text(
'+ ₹${taxAmount.toStringAsFixed(2)}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Item Total:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
Text(
'${calculatedTotal.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
),
],
),
],
),
);
}
Widget _buildSmallBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5),
),
child: Text(
text,
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color),
),
);
}
Widget _buildTotalsSummaryCard(bool isDark) {
final businessProfile = ref.watch(businessProfileProvider).value;
final businessStateId = businessProfile?.stateId;
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
final customers = ref.watch(customersProvider).value ?? [];
final customer = _selectedCustomerId == null
? null
: customers.where((c) => c.id == _selectedCustomerId).firstOrNull;
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
final effectiveSupplyStateId = _selectedPlaceOfSupplyStateId ?? customer?.stateId ?? businessStateId;
final isSameState = businessStateId != null && effectiveSupplyStateId != null && businessStateId == effectiveSupplyStateId;
final hasCommodityItems = _items.any((item) {
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
return _isCommodityProduct(category: category, product: product, invoiceItem: item);
});
double itemsSubtotal = 0;
double makingChargesTotal = 0;
double rawTaxableTotal = 0;
double taxTotal = 0;
for (var item in _items) {
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final baseAmt = qtyOrWeight * item.unitPrice;
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
double makingAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = baseAmt * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = qtyOrWeight * item.makingCharge;
}
}
final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount;
final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0)));
final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0;
itemsSubtotal += baseAmt;
makingChargesTotal += makingAmt;
rawTaxableTotal += (baseAmt + makingAmt + item.otherCharges);
taxTotal += itemTax;
}
final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity);
if (rawTaxableTotal > 0 && discountAmount > 0) {
taxTotal = taxTotal * (effectiveTaxable / rawTaxableTotal);
}
final grandTotal = effectiveTaxable + taxTotal;
final effectiveGstRate = (effectiveTaxable > 0 && taxTotal > 0)
? (taxTotal / effectiveTaxable) * 100.0
: (_items.isNotEmpty ? _items.first.taxRate : (hasCommodityItems ? 3.0 : 18.0));
final cgstRate = effectiveGstRate / 2.0;
final sgstRate = effectiveGstRate / 2.0;
final igstRate = effectiveGstRate;
String formatRate(double rate) {
if (rate <= 0) return '0%';
final rounded = (rate * 100).round() / 100;
if (rounded.truncateToDouble() == rounded) {
return '${rounded.toInt()}%';
}
return '${rounded.toStringAsFixed(1)}%';
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('INVOICE SUMMARY', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)),
const SizedBox(height: 14),
if (hasCommodityItems) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Metal Subtotal:'),
Text('${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
if (makingChargesTotal > 0) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Making Charges:'),
Text('${makingChargesTotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Items Subtotal (Taxable):'),
Text('${itemsSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
if (discountAmount > 0) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Discount:', style: TextStyle(color: Colors.red, fontWeight: FontWeight.w600)),
Text('- ₹${discountAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
],
),
],
const SizedBox(height: 8),
if (isSameState) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('CGST (${formatRate(cgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('SGST (${formatRate(sgstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('IGST (${formatRate(igstRate)}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${taxTotal.toStringAsFixed(2)}'),
],
),
],
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Grand Total:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(
'${grandTotal.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
),
],
),
],
),
);
}
Widget _buildPaymentSection(bool isDark) {
final wallets = ref.watch(walletProvider).value ?? [];
final activeWallets = wallets.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList();
// Compute grand total for live balance due
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
double rawTaxable = 0;
double taxTotal = 0;
for (var item in _items) {
final product = products.where((p) => p.id == item.productId).firstOrNull;
final category = categories.where((c) => c.name == item.categoryName || c.id == product?.categoryId).firstOrNull;
final qtyOrWeight = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0);
final baseAmt = qtyOrWeight * item.unitPrice;
final isItemCommodity = _isCommodityProduct(category: category, product: product, invoiceItem: item);
double makingAmt = 0.0;
if (isItemCommodity) {
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = baseAmt * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = qtyOrWeight * item.makingCharge;
}
}
final itemTaxable = baseAmt + makingAmt + item.otherCharges - item.discount;
final itemGstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? (category?.defaultGst ?? (isItemCommodity ? 3.0 : 18.0)));
final itemTax = (itemTaxable.clamp(0.0, double.infinity) * itemGstRate) / 100.0;
rawTaxable += (baseAmt + makingAmt + item.otherCharges);
taxTotal += itemTax;
}
final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity);
if (rawTaxable > 0 && discountAmount > 0) {
taxTotal = taxTotal * (effectiveTaxable / rawTaxable);
}
final grandTotal = effectiveTaxable + taxTotal;
// If user has not manually changed amount paid and grandTotal > 0, auto-track grand total
if (!_isAmountPaidEdited && grandTotal > 0 && _amountPaidCtrl.text != grandTotal.toStringAsFixed(2)) {
_amountPaidCtrl.text = grandTotal.toStringAsFixed(2);
}
final enteredAmount = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
final amountPaid = enteredAmount > grandTotal ? grandTotal : enteredAmount;
final balanceDue = grandTotal - amountPaid;
Widget? statusBadge;
if (grandTotal > 0 && (_isAmountPaidEdited || _amountPaidCtrl.text.isNotEmpty)) {
if (amountPaid >= grandTotal) {
statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Fully Paid',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.green),
),
);
} else if (amountPaid > 0) {
statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Partial Payment',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.orange),
),
);
} else {
statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'Unpaid',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.red),
),
);
}
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('PAYMENT & SETTLEMENT', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 0.8, color: Colors.grey)),
if (statusBadge != null)
Flexible(child: statusBadge),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _amountPaidCtrl,
labelText: 'Amount Received (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee),
onChanged: (val) {
setState(() => _isAmountPaidEdited = true);
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _paymentMethod,
decoration: InputDecoration(
labelText: 'Method',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: const [
DropdownMenuItem(value: 'Cash', child: Text('Cash')),
DropdownMenuItem(value: 'UPI', child: Text('UPI / QR')),
DropdownMenuItem(value: 'Bank Transfer', child: Text('Bank Transfer')),
DropdownMenuItem(value: 'Card', child: Text('Card')),
],
onChanged: (val) => setState(() => _paymentMethod = val!),
),
),
],
),
const SizedBox(height: 14),
if (activeWallets.isNotEmpty)
DropdownButtonFormField<int>(
value: _selectedWalletId ?? activeWallets.first.id,
decoration: InputDecoration(
labelText: 'Deposit To Account',
prefixIcon: const Icon(LucideIcons.wallet),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: activeWallets.map((w) => DropdownMenuItem(
value: w.id,
child: Text('${w.name} (₹${w.balance.toStringAsFixed(0)})'),
)).toList(),
onChanged: (val) => setState(() => _selectedWalletId = val),
),
if (balanceDue > 0) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: (amountPaid > 0 ? Colors.orange : Colors.red).withValues(alpha: 0.25)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
amountPaid > 0 ? LucideIcons.circleDot : LucideIcons.alertCircle,
size: 16,
color: amountPaid > 0 ? Colors.orange : Colors.red,
),
const SizedBox(width: 8),
Text(
amountPaid > 0 ? 'Remaining Balance Due:' : 'Total Amount Due:',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
],
),
Text(
'${balanceDue.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: amountPaid > 0 ? Colors.orange.shade700 : Colors.red.shade700,
),
),
],
),
),
const SizedBox(height: 14),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)),
firstDate: DateTime.now(),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => _nextPaymentDate = picked);
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Next Due / Balance Settlement Date',
prefixIcon: const Icon(LucideIcons.calendarClock),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(
_nextPaymentDate != null ? DateFormat('dd MMM yyyy').format(_nextPaymentDate!) : 'Select Settlement Date (Default: 30 Days)',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
),
),
],
const Divider(height: 28),
// EMI Option Toggle
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(LucideIcons.creditCard, size: 18, color: Colors.purple),
SizedBox(width: 8),
Text('Enable EMI Installments', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
],
),
Switch(
value: _isEmi,
activeColor: Colors.purple,
onChanged: (val) => setState(() => _isEmi = val),
),
],
),
if (_isEmi) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _emiAmountCtrl,
labelText: 'EMI Amount (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _emiCycle,
decoration: InputDecoration(
labelText: 'Cycle',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: const [
DropdownMenuItem(value: 'MONTHLY', child: Text('Monthly')),
DropdownMenuItem(value: 'WEEKLY', child: Text('Weekly')),
],
onChanged: (val) => setState(() => _emiCycle = val!),
),
),
],
),
],
],
),
);
}
}
class _AddSalesItemSheet extends ConsumerStatefulWidget {
final ValueChanged<InvoiceItem> onItemAdded;
final InvoiceItem? initialItem;
const _AddSalesItemSheet({
required this.onItemAdded,
this.initialItem,
});
@override
ConsumerState<_AddSalesItemSheet> createState() => _AddSalesItemSheetState();
}
class _AddSalesItemSheetState extends ConsumerState<_AddSalesItemSheet> {
final _searchCtrl = TextEditingController();
Product? _selectedProduct;
InventoryItem? _selectedInventoryItem;
double _selectedProductAvailableStock = 0.0;
final _unitPriceCtrl = TextEditingController();
final _quantityCtrl = TextEditingController(text: '1');
final _weightCtrl = TextEditingController(text: '1.000');
final _makingChargeCtrl = TextEditingController(text: '0.0');
String _makingChargeType = 'PER_GRAM';
final _discountCtrl = TextEditingController(text: '0.0');
@override
void initState() {
super.initState();
Future.microtask(() => ref.invalidate(inventoryItemsProvider));
if (widget.initialItem != null) {
final it = widget.initialItem!;
_makingChargeCtrl.text = it.makingCharge.toStringAsFixed(2);
_makingChargeType = it.makingChargesType ?? 'PER_GRAM';
_discountCtrl.text = it.discount.toStringAsFixed(2);
_unitPriceCtrl.text = it.unitPrice.toStringAsFixed(2);
_quantityCtrl.text = (it.quantity > 0 ? it.quantity : 1.0).toStringAsFixed(it.quantity.truncateToDouble() == it.quantity ? 0 : 2);
_weightCtrl.text = (it.weight ?? 1.0).toStringAsFixed(3);
WidgetsBinding.instance.addPostFrameCallback((_) {
final products = ref.read(productsProvider).value ?? [];
final invItems = ref.read(inventoryItemsProvider).value ?? [];
setState(() {
_selectedProduct = products.where((p) => p.id == it.productId).firstOrNull ??
Product(
id: it.productId,
name: it.productName ?? 'Item',
sku: it.sku,
hsnCode: it.hsnCode,
gstRate: it.taxRate,
sellingPrice: it.unitPrice,
makingCharges: it.makingCharge,
makingChargesType: it.makingChargesType,
);
if (it.inventoryItemId != null) {
_selectedInventoryItem = invItems.where((i) => i.id == it.inventoryItemId).firstOrNull;
}
});
});
}
}
@override
void dispose() {
_searchCtrl.dispose();
_unitPriceCtrl.dispose();
_quantityCtrl.dispose();
_weightCtrl.dispose();
_makingChargeCtrl.dispose();
_discountCtrl.dispose();
super.dispose();
}
void _onProductSelected(
Product product,
ProductCategory? category,
double liveCommodityRate,
bool isCommodity, {
double? avgPrice,
double? availableStock,
}) {
setState(() {
_selectedProduct = product;
_selectedInventoryItem = null;
_selectedProductAvailableStock = availableStock ?? product.currentStock ?? 0.0;
final resolvedPrice = (avgPrice != null && avgPrice > 0)
? avgPrice
: (product.sellingPrice ?? 0.0);
_unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : '';
_quantityCtrl.text = '1';
_weightCtrl.text = '1.000';
if (product.makingCharges != null && product.makingCharges! > 0) {
_makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2);
_makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM');
} else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) {
_makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2);
_makingChargeType = category.makingChargeType ?? 'PER_GRAM';
} else {
_makingChargeCtrl.text = '0.0';
_makingChargeType = category?.makingChargeType ?? 'PER_GRAM';
}
});
}
void _onInventoryItemSelected(InventoryItem item, Product product, ProductCategory? category, bool isCommodity) {
setState(() {
_selectedProduct = product;
_selectedInventoryItem = item;
_selectedProductAvailableStock = item.grossWeight ?? item.netWeight ?? 1.0;
final resolvedPrice = item.saleRate ?? product.sellingPrice ?? 0.0;
_unitPriceCtrl.text = resolvedPrice > 0 ? resolvedPrice.toStringAsFixed(2) : '';
_quantityCtrl.text = '1';
_weightCtrl.text = (item.grossWeight ?? 1.0).toStringAsFixed(3);
if (item.makingCharges != null && item.makingCharges! > 0) {
_makingChargeCtrl.text = item.makingCharges!.toStringAsFixed(2);
_makingChargeType = item.makingChargeType ?? 'PER_GRAM';
} else if (product.makingCharges != null && product.makingCharges! > 0) {
_makingChargeCtrl.text = product.makingCharges!.toStringAsFixed(2);
_makingChargeType = product.makingChargesType ?? (category?.makingChargeType ?? 'PER_GRAM');
} else if (category?.defaultMakingCharge != null && category!.defaultMakingCharge! > 0) {
_makingChargeCtrl.text = category.defaultMakingCharge!.toStringAsFixed(2);
_makingChargeType = category.makingChargeType ?? 'PER_GRAM';
} else {
_makingChargeCtrl.text = '0.0';
_makingChargeType = category?.makingChargeType ?? 'PER_GRAM';
}
});
}
Future<void> _scanBarcodeInSheet() async {
final scanned = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
);
if (scanned != null && scanned.isNotEmpty) {
_searchCtrl.text = scanned;
setState(() {});
}
}
void _submitItem(ProductCategory? category, double unitRate, bool isCommodity, String uomUnit) {
if (_selectedProduct == null) return;
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
double effectivePrice = unitRate;
double qty = 1.0;
double? weight;
double makingCharge = 0.0;
String makingType = 'PER_PIECE';
double lineTaxable = 0.0;
double taxAmount = 0.0;
double total = 0.0;
if (isCommodity) {
weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0);
qty = 1.0;
effectivePrice = unitRate;
makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
makingType = _makingChargeType;
final metalAmount = weight * effectivePrice;
double makingAmt = 0.0;
if (makingType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (makingType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
lineTaxable = metalAmount + makingAmt - discount;
taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0;
total = lineTaxable.clamp(0.0, double.infinity) + taxAmount;
} else {
effectivePrice = double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? unitRate);
qty = double.tryParse(_quantityCtrl.text) ?? 1.0;
weight = null;
makingCharge = 0.0;
makingType = 'PER_PIECE';
lineTaxable = (qty * effectivePrice) - discount;
taxAmount = (lineTaxable.clamp(0.0, double.infinity) * gstRate) / 100.0;
total = lineTaxable.clamp(0.0, double.infinity) + taxAmount;
}
final item = InvoiceItem(
productId: _selectedProduct!.id,
inventoryItemId: _selectedInventoryItem?.id,
productName: _selectedProduct!.name,
categoryName: category?.name,
commodityCode: isCommodity ? category?.commodityCode : null,
hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode,
sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku,
huid: isCommodity ? _selectedInventoryItem?.huid : null,
description: _selectedProduct!.name,
quantity: qty,
weight: weight,
unitPrice: effectivePrice,
makingCharge: makingCharge,
makingChargesType: makingType,
taxRate: gstRate,
discount: discount,
photoUrl: _selectedInventoryItem?.tagNumber,
total: total,
);
widget.onItemAdded(item);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final products = ref.watch(productsProvider).value ?? [];
final categories = ref.watch(productCategoriesProvider).value ?? [];
final commodityRates = ref.watch(commodityRatesProvider).value ?? [];
final inventoryItems = ref.watch(inventoryItemsProvider).value ?? [];
final uoms = ref.watch(uomsProvider).value ?? [];
final businessProfile = ref.watch(businessProfileProvider).value;
final nature = (businessProfile?.natureOfBusiness ?? businessProfile?.industry ?? 'JEWELLERY').toUpperCase();
final isJewellery = nature == 'JEWELLERY';
final isDark = Theme.of(context).brightness == Brightness.dark;
ProductCategory? category;
if (_selectedProduct?.categoryId != null) {
category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull;
}
final isCommodity = _isCommodityProduct(
category: category,
product: _selectedProduct,
inventoryItem: _selectedInventoryItem,
);
final uomUnit = _resolveProductUnit(product: _selectedProduct, category: category, uoms: uoms, isCommodity: isCommodity);
double commodityRate = 0.0;
if (category?.commodityCode != null) {
final match = commodityRates.where((r) => r.commodityCode.toUpperCase() == category!.commodityCode!.toUpperCase()).firstOrNull;
if (match != null) {
commodityRate = match.rate;
}
}
final purityFactor = resolvePurity(categoryPurity: category?.purityFactor, productPurity: _selectedProduct?.purityFactor);
final effectiveUnitRate = isCommodity
? (commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0))
: (double.tryParse(_unitPriceCtrl.text) ?? (_selectedProduct?.sellingPrice ?? 0.0));
final weight = double.tryParse(_weightCtrl.text) ?? (_selectedInventoryItem?.grossWeight ?? 1.0);
final quantity = double.tryParse(_quantityCtrl.text) ?? 1.0;
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
double taxable = 0.0;
double makingAmt = 0.0;
double metalAmount = 0.0;
if (isCommodity) {
metalAmount = weight * effectiveUnitRate;
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
if (_makingChargeType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (_makingChargeType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
taxable = (metalAmount + makingAmt - discount).clamp(0.0, double.infinity);
} else {
final subtotal = quantity * effectiveUnitRate;
taxable = (subtotal - discount).clamp(0.0, double.infinity);
}
final taxAmount = (taxable * gstRate) / 100.0;
final total = taxable + taxAmount;
final query = _searchCtrl.text.trim().toLowerCase();
final availableItems = inventoryItems
.where((item) => item.status == null || item.status == 'AVAILABLE')
.toList();
availableItems.sort((a, b) {
if (a.createdAt == null && b.createdAt == null) return 0;
if (a.createdAt == null) return 1;
if (b.createdAt == null) return -1;
return b.createdAt!.compareTo(a.createdAt!);
});
final List<InventoryItem> displayInventoryItems;
if (query.isEmpty) {
displayInventoryItems = availableItems.take(20).toList();
} else {
displayInventoryItems = availableItems.where((item) {
final p = products.where((pr) => pr.id == item.productId).firstOrNull;
final cat = p != null ? categories.where((c) => c.id == p.categoryId).firstOrNull : null;
final matchesHuid = item.huid != null && item.huid!.toLowerCase().contains(query);
final matchesSku = (item.sku != null && item.sku!.toLowerCase().contains(query)) ||
(p?.sku != null && p!.sku!.toLowerCase().contains(query));
final matchesName = p != null && p.name.toLowerCase().contains(query);
final matchesBarcode = p != null && p.barcode != null && p.barcode!.toLowerCase().contains(query);
final matchesCategory = cat != null && cat.name.toLowerCase().contains(query);
final matchesTag = item.tagNumber != null && item.tagNumber!.toLowerCase().contains(query);
return matchesHuid || matchesSku || matchesName || matchesBarcode || matchesCategory || matchesTag;
}).take(20).toList();
}
final matchingProductIdsInInv = displayInventoryItems.map((i) => i.productId).toSet();
final directProducts = products.where((p) {
if (matchingProductIdsInInv.contains(p.id)) return false;
if (query.isEmpty) return true;
final q = query.toLowerCase();
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
return p.name.toLowerCase().contains(q) ||
(p.sku != null && p.sku!.toLowerCase().contains(q)) ||
(p.barcode != null && p.barcode!.toLowerCase().contains(q)) ||
(cat != null && cat.name.toLowerCase().contains(q));
}).take(20).toList();
final matchedProducts = products.where((p) {
if (query.isEmpty) return true;
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
return p.name.toLowerCase().contains(query) ||
(p.sku != null && p.sku!.toLowerCase().contains(query)) ||
(p.barcode != null && p.barcode!.toLowerCase().contains(query)) ||
(cat != null && cat.name.toLowerCase().contains(query));
}).toList();
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
return AnimatedPadding(
padding: EdgeInsets.only(bottom: keyboardHeight),
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
behavior: HitTestBehavior.translucent,
child: Container(
height: MediaQuery.of(context).size.height * 0.88,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
child: Column(
children: [
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Add Item to Invoice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (keyboardHeight > 0)
TextButton.icon(
style: TextButton.styleFrom(
backgroundColor: Colors.blue.withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(LucideIcons.keyboard, size: 16, color: Colors.blue),
label: const Text('Done', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
onPressed: () => FocusScope.of(context).unfocus(),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
],
),
),
const Divider(height: 1),
// Keyboard Toolbar when active on iOS/Android
if (keyboardHeight > 0)
Container(
color: isDark ? const Color(0xFF1E293B) : Colors.grey.shade100,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Editing ${_selectedProduct?.name ?? 'Item'}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(LucideIcons.check, size: 15),
label: const Text('Done / Dismiss', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
onPressed: () => FocusScope.of(context).unfocus(),
),
],
),
),
Expanded(
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 40),
children: [
// Search Input with Barcode Action & Rounded Borders
TextField(
controller: _searchCtrl,
decoration: InputDecoration(
labelText: isJewellery
? 'Search Product by HUID / Name / SKU / Barcode'
: 'Search Product by Name / SKU / Barcode',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_searchCtrl.text.isNotEmpty)
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _searchCtrl.clear()),
),
IconButton(
icon: const Icon(LucideIcons.qrCode, color: Colors.blue),
tooltip: 'Scan Barcode',
onPressed: _scanBarcodeInSheet,
),
],
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
onChanged: (val) => setState(() {}),
),
const SizedBox(height: 12),
if (_selectedProduct == null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
!isJewellery
? (query.isEmpty ? 'Available Products in Catalog (${matchedProducts.length}):' : 'Matched Products (${matchedProducts.length}):')
: (query.isEmpty ? 'Available Stock in Inventory (${displayInventoryItems.length + directProducts.length}):' : 'Matched Items (${displayInventoryItems.length + directProducts.length}):'),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
],
),
const SizedBox(height: 10),
if (!isJewellery) ...[
// NON-JEWELLERY MODULE: Show List of Unique Products with Weighted Avg Price & Aggregated Available Stock
if (matchedProducts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400),
const SizedBox(height: 10),
Text(
query.isEmpty
? 'No products available in catalog'
: 'No matching products found',
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
],
),
),
)
else ...[
...matchedProducts.map((p) {
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: false);
// Find all available batches for this product in inventory
final pBatches = availableItems.where((i) => i.productId == p.id).toList();
double totalAvailableStock = 0.0;
double totalCostOrPrice = 0.0;
for (var batch in pBatches) {
final qty = (batch.grossWeight != null && batch.grossWeight! > 0)
? batch.grossWeight!
: ((batch.netWeight != null && batch.netWeight! > 0) ? batch.netWeight! : 1.0);
final rate = (batch.saleRate != null && batch.saleRate! > 0)
? batch.saleRate!
: (batch.purchaseCost != null && batch.purchaseCost! > 0 ? batch.purchaseCost! : (p.sellingPrice ?? 0.0));
totalAvailableStock += qty;
totalCostOrPrice += (qty * rate);
}
if (pBatches.isEmpty && (p.currentStock != null && p.currentStock! > 0)) {
totalAvailableStock = p.currentStock!;
totalCostOrPrice = totalAvailableStock * (p.sellingPrice ?? 0.0);
}
final avgPrice = totalAvailableStock > 0
? (totalCostOrPrice / totalAvailableStock)
: (p.sellingPrice ?? 0.0);
return _buildProductSearchCard(
product: p,
category: cat,
availableStock: totalAvailableStock,
avgPrice: avgPrice,
uomUnit: itemUom,
isDark: isDark,
onTap: () => _onProductSelected(
p,
cat,
0.0,
false,
avgPrice: avgPrice,
availableStock: totalAvailableStock,
),
);
}),
],
] else ...[
// JEWELLERY MODULE: Show Serialized Inventory Items (HUID, Tag No, Purity, Gross Weight)
if (displayInventoryItems.isEmpty && directProducts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.packageOpen, size: 40, color: Colors.grey.shade400),
const SizedBox(height: 10),
Text(
query.isEmpty
? 'No items available in stock'
: 'No matching items found',
style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
),
],
),
),
)
else ...[
...displayInventoryItems.map((item) {
final p = products.where((pr) => pr.id == item.productId).firstOrNull ??
Product(
id: item.productId,
name: item.sku ?? 'Inventory Item',
sku: item.sku,
);
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
final itemIsCommodity = _isCommodityProduct(category: cat, product: p, inventoryItem: item);
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity);
double commRate = 0.0;
if (cat?.commodityCode != null) {
final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
if (m != null) commRate = m.rate;
}
return _buildSearchItemCard(
product: p,
category: cat,
inventoryItem: item,
commodityRate: commRate,
isCommodity: itemIsCommodity,
uomUnit: itemUom,
isDark: isDark,
onTap: () => _onInventoryItemSelected(item, p, cat, itemIsCommodity),
);
}),
...directProducts.map((p) {
final cat = categories.where((c) => c.id == p.categoryId).firstOrNull;
final itemIsCommodity = _isCommodityProduct(category: cat, product: p);
final itemUom = _resolveProductUnit(product: p, category: cat, uoms: uoms, isCommodity: itemIsCommodity);
double commRate = 0.0;
if (cat?.commodityCode != null) {
final m = commodityRates.where((r) => r.commodityCode.toUpperCase() == cat!.commodityCode!.toUpperCase()).firstOrNull;
if (m != null) commRate = m.rate;
}
return _buildSearchItemCard(
product: p,
category: cat,
inventoryItem: null,
commodityRate: commRate,
isCommodity: itemIsCommodity,
uomUnit: itemUom,
isDark: isDark,
onTap: () => _onProductSelected(p, cat, commRate, itemIsCommodity),
);
}),
],
],
] else ...[
// SELECTED PRODUCT DETAILS CARD
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.blue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
_selectedProduct!.name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
TextButton(
onPressed: () => setState(() {
_selectedProduct = null;
_selectedInventoryItem = null;
}),
child: const Text('Change'),
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (category?.name != null)
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (_selectedProduct!.sku != null)
_buildSheetBadge('SKU: ${_selectedProduct!.sku}', Colors.blue),
if (isCommodity && _selectedInventoryItem?.huid != null)
_buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple),
if (isCommodity && category?.commodityCode != null)
_buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal),
if (isCommodity)
_buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800),
if (!isCommodity) ...[
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
if (_selectedProductAvailableStock > 0)
_buildSheetBadge('Available Stock: ${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit', Colors.green.shade800),
],
],
),
],
),
),
const SizedBox(height: 16),
if (isCommodity) ...[
// COMMODITY / JEWELLERY FORM
Row(
children: [
Expanded(
child: TextFormField(
initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '7113',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
enabled: false,
decoration: InputDecoration(
labelText: 'HSN Code',
prefixIcon: const Icon(LucideIcons.hash, size: 18),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: PremiumTextField(
controller: _weightCtrl,
labelText: 'Weight ($uomUnit)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.scale, size: 18),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
key: ValueKey(effectiveUnitRate),
initialValue: '${effectiveUnitRate.toStringAsFixed(2)}/$uomUnit',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
enabled: false,
decoration: InputDecoration(
labelText: 'Live Rate',
prefixIcon: const Icon(LucideIcons.trendingUp, size: 18),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
key: ValueKey(metalAmount),
initialValue: '${metalAmount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
enabled: false,
decoration: InputDecoration(
labelText: 'Metal Amount',
prefixIcon: const Icon(LucideIcons.coins, size: 18),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _makingChargeCtrl,
labelText: 'Making Charges',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.hammer),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _makingChargeType,
decoration: InputDecoration(
labelText: 'Type',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: const [
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')),
DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage %')),
],
onChanged: (val) => setState(() => _makingChargeType = val!),
),
),
],
),
if (makingAmt > 0) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Calculated Making Charge:',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
Text(
'${makingAmt.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.indigo),
),
],
),
),
],
const SizedBox(height: 12),
PremiumTextField(
controller: _discountCtrl,
labelText: 'Item Discount (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.tag),
onChanged: (_) => setState(() {}),
),
] else ...[
// E-COMMERCE / GENERAL / NON-COMMODITY FORM
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _unitPriceCtrl,
labelText: 'Unit Price (₹ / $uomUnit)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PremiumTextField(
controller: _quantityCtrl,
labelText: 'Quantity ($uomUnit)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.package),
onChanged: (_) => setState(() {}),
),
if (_selectedProductAvailableStock > 0 &&
(double.tryParse(_quantityCtrl.text) ?? 0.0) > _selectedProductAvailableStock) ...[
const SizedBox(height: 4),
Text(
'Exceeds available stock (${_selectedProductAvailableStock.toStringAsFixed(_selectedProductAvailableStock.truncateToDouble() == _selectedProductAvailableStock ? 0 : 2)} $uomUnit)',
style: const TextStyle(fontSize: 11, color: Colors.orange, fontWeight: FontWeight.w600),
),
],
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _discountCtrl,
labelText: 'Item Discount (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.tag),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
initialValue: category?.defaultHsn ?? _selectedProduct?.hsnCode ?? '-',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
enabled: false,
decoration: InputDecoration(
labelText: 'HSN Code',
prefixIcon: const Icon(LucideIcons.hash, size: 18),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
],
const SizedBox(height: 16),
// Calculated Subtotal & GST Display
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? Colors.black26 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Taxable Subtotal:'),
Text('${taxable.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('GST (${gstRate.toStringAsFixed(1)}%):'),
Text('+ ₹${taxAmount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo)),
],
),
const Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Item Total:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text('${total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green)),
],
),
],
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _submitItem(category, effectiveUnitRate, isCommodity, uomUnit),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Add to Invoice', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
],
),
),
],
),
),
),
);
}
Widget _buildSheetBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Text(
text,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: color),
),
);
}
Widget _buildProductSearchCard({
required Product product,
ProductCategory? category,
required double availableStock,
required double avgPrice,
required String uomUnit,
required bool isDark,
required VoidCallback onTap,
}) {
final gstRate = product.gstRate ?? (category?.defaultGst ?? 18.0);
final unitWithGst = avgPrice > 0 ? avgPrice * (1 + gstRate / 100.0) : 0.0;
final totalStockValue = availableStock * avgPrice;
final sku = product.sku;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
LucideIcons.package,
size: 20,
color: Colors.blue,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (category?.name != null)
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty)
_buildSheetBadge('SKU: $sku', Colors.blue),
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
],
),
],
),
),
],
),
const Divider(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
avgPrice > 0
? 'Avg Price: ₹${avgPrice.toStringAsFixed(2)} / $uomUnit'
: 'Price: Not Set',
style: TextStyle(
fontSize: 13,
color: avgPrice > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 3),
Text(
'Available Stock: ${availableStock.toStringAsFixed(availableStock.truncateToDouble() == availableStock ? 0 : 2)} $uomUnit',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: availableStock > 0 ? Colors.green.shade700 : Colors.red.shade600,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (avgPrice > 0 && availableStock > 0) ...[
Text(
'${totalStockValue.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
),
Text(
'${unitWithGst.toStringAsFixed(2)}/$uomUnit (incl. ${gstRate.toStringAsFixed(1)}% GST)',
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
),
] else if (avgPrice > 0) ...[
Text(
'${unitWithGst.toStringAsFixed(2)} / $uomUnit',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Colors.green),
),
Text(
'incl. ${gstRate.toStringAsFixed(1)}% GST',
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
),
] else ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: const Text(
'Tap to enter price',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
),
),
],
],
),
],
),
],
),
),
),
),
);
}
Widget _buildSearchItemCard({
required Product product,
ProductCategory? category,
InventoryItem? inventoryItem,
required double commodityRate,
required bool isCommodity,
required String uomUnit,
required bool isDark,
required VoidCallback onTap,
}) {
final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor);
final unitRate = isCommodity
? (commodityRate > 0 ? (commodityRate * purity) : (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0))
: (inventoryItem?.saleRate ?? product.sellingPrice ?? 0.0);
final weight = inventoryItem?.grossWeight ?? 1.0;
final baseAmount = weight * unitRate;
final makingCharge = (inventoryItem?.makingCharges != null && inventoryItem!.makingCharges! > 0)
? inventoryItem.makingCharges!
: ((product.makingCharges != null && product.makingCharges! > 0)
? product.makingCharges!
: (category?.defaultMakingCharge ?? 0.0));
final makingType = (inventoryItem?.makingChargeType != null && inventoryItem!.makingChargeType!.isNotEmpty)
? inventoryItem.makingChargeType!
: ((product.makingCharges != null && product.makingCharges! > 0 && product.makingChargesType != null)
? product.makingChargesType!
: (category?.makingChargeType ?? 'PER_GRAM'));
double makingAmt = 0.0;
if (isCommodity) {
if (makingType == 'PERCENTAGE') {
makingAmt = baseAmount * (makingCharge / 100.0);
} else if (makingType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
}
final gstRate = product.gstRate ?? (category?.defaultGst ?? (isCommodity ? 3.0 : 18.0));
final taxable = baseAmount + makingAmt;
final taxAmount = (taxable * gstRate) / 100.0;
final totalWithTax = taxable + taxAmount;
final huid = inventoryItem?.huid;
final sku = inventoryItem?.sku ?? product.sku;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isCommodity ? (huid != null ? LucideIcons.gem : LucideIcons.sparkles) : LucideIcons.tag,
size: 20,
color: isCommodity ? (huid != null ? Colors.purple : const Color(0xFFD4AF37)) : Colors.blue,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (category?.name != null)
_buildSheetBadge(category!.name, const Color(0xFFD4AF37)),
if (sku != null && sku.isNotEmpty)
_buildSheetBadge('SKU: $sku', Colors.blue),
if (isCommodity && huid != null && huid.isNotEmpty)
_buildSheetBadge('HUID: $huid', Colors.purple),
if (isCommodity)
_buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900),
if (!isCommodity)
_buildSheetBadge('Unit: $uomUnit', Colors.teal),
],
),
],
),
),
],
),
const Divider(height: 18),
if (isCommodity) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Rate: ₹${unitRate.toStringAsFixed(2)}/$uomUnit${weight.toStringAsFixed(3)}$uomUnit',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
),
Text(
'Metal: ₹${baseAmount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
),
child: Text(
'Making: ${makingType == 'PERCENTAGE' ? '$makingCharge%' : (makingType == 'PER_PIECE' ? '$makingCharge/pc' : '$makingCharge/g')} (₹${makingAmt.toStringAsFixed(2)})',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.indigo),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${totalWithTax.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
),
Text(
'incl. ${gstRate.toStringAsFixed(1)}% GST',
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
),
],
),
],
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
unitRate > 0
? 'Price: ₹${unitRate.toStringAsFixed(2)} / $uomUnit'
: 'Price: Not Set',
style: TextStyle(
fontSize: 13,
color: unitRate > 0 ? (isDark ? Colors.white70 : Colors.grey.shade800) : Colors.orange.shade700,
fontWeight: FontWeight.w600,
),
),
if (inventoryItem?.grossWeight != null && inventoryItem!.grossWeight! > 0) ...[
const SizedBox(height: 2),
Text(
'In Stock: ${(inventoryItem!.grossWeight!).toStringAsFixed((inventoryItem!.grossWeight!).truncateToDouble() == inventoryItem!.grossWeight! ? 0 : 2)} $uomUnit',
style: TextStyle(fontSize: 11, color: isDark ? Colors.white54 : Colors.grey.shade600),
),
],
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (unitRate > 0) ...[
Text(
'${totalWithTax.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.green),
),
Text(
'incl. ${gstRate.toStringAsFixed(1)}% GST',
style: TextStyle(fontSize: 10, color: Colors.grey.shade500),
),
] else ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: const Text(
'Tap to enter price',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue),
),
),
],
],
),
],
),
],
],
),
),
),
),
);
}
}