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

2154 lines
88 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_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/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/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 '../../business/providers/business_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';
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');
DateTime _invoiceDate = DateTime.now();
DateTime? _dueDate;
int? _selectedCustomerId;
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;
_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();
super.dispose();
}
Future<void> _pickInvoiceFile() async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (picked != null) {
setState(() => _invoiceFile = picked);
}
}
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) {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
_invoiceFile!.path,
filename: _invoiceFile!.name,
),
'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 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 isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId;
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 gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 0.0);
final weight = item.weight ?? item.quantity;
final metalAmount = weight * item.unitPrice;
double makingChargeAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge;
} else {
makingChargeAmt = weight * item.makingCharge;
}
final taxableAmount = metalAmount + 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 += metalAmount;
totalMaking += makingChargeAmt;
totalTax += taxAmount;
processedItems.add(item.copyWith(
taxRate: gstRate,
cgst: cgst,
sgst: sgst,
igst: igst,
total: lineTotal,
));
}
final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0;
totalDiscount = invoiceDiscount;
grandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax;
final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
final validAmountPaid = amountPaid > grandTotal ? grandTotal : amountPaid;
final balanceDue = grandTotal - validAmountPaid;
final invoice = Invoice(
id: widget.existingInvoice?.id,
customerId: _selectedCustomerId,
invoiceNumber: _invoiceNumberCtrl.text.trim(),
issueDate: _invoiceDate,
dueDate: _dueDate,
subtotal: subtotal,
taxTotal: totalTax,
cgstTotal: totalCgst,
sgstTotal: totalSgst,
igstTotal: totalIgst,
discountTotal: totalDiscount,
totalAmount: grandTotal,
amountPaid: validAmountPaid,
paymentMethod: validAmountPaid > 0 ? _paymentMethod : null,
paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null,
nextPaymentDate: balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
status: widget.existingInvoice?.status ?? (validAmountPaid >= grandTotal ? 'PAID' : (validAmountPaid > 0 ? 'PARTIAL' : 'DRAFT')),
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,
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: [
// 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;
});
},
),
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: 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(
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(
children: [
Icon(LucideIcons.shoppingBag, 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/HUID to add jewellery',
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: [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 weight = item.weight ?? item.quantity;
final metalAmount = weight * item.unitPrice;
double makingChargeAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingChargeAmt = metalAmount * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingChargeAmt = item.makingCharge;
} else {
makingChargeAmt = weight * item.makingCharge;
}
final calculatedTotal = item.total > 0 ? item.total : (metalAmount + makingChargeAmt + item.otherCharges - item.discount);
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 (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),
],
),
],
),
),
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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Metal: ${weight.toStringAsFixed(3)}g ×${item.unitPrice.toStringAsFixed(2)}',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
Text(
'${metalAmount.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
),
],
),
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),
),
],
),
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(
'+ ₹${((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)).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 products = ref.watch(productsProvider).value ?? [];
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 discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
final isSameState = businessStateId != null && customer?.stateId != null && businessStateId == customer!.stateId;
double metalSubtotal = 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 gstRate = item.taxRate > 0 ? item.taxRate : (product?.gstRate ?? 3.0);
final weight = item.weight ?? item.quantity;
final metalAmt = weight * item.unitPrice;
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = metalAmt * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = weight * item.makingCharge;
}
metalSubtotal += metalAmt;
makingChargesTotal += makingAmt;
rawTaxableTotal += (metalAmt + makingAmt + item.otherCharges);
}
final effectiveTaxable = (rawTaxableTotal - discountAmount).clamp(0.0, double.infinity);
final avgGstRate = _items.isNotEmpty
? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length)
: 3.0;
taxTotal = (effectiveTaxable * avgGstRate) / 100.0;
final grandTotal = effectiveTaxable + taxTotal;
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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Metal Subtotal:'),
Text('${metalSubtotal.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Making Charges:'),
Text('${makingChargesTotal.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 (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('SGST (${(taxTotal > 0 ? '1.5%' : '0%')}):', style: TextStyle(color: Colors.grey.shade600)),
Text('${(taxTotal / 2.0).toStringAsFixed(2)}'),
],
),
] else ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('IGST (${(taxTotal > 0 ? '3.0%' : '0%')}):', 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 products = ref.watch(productsProvider).value ?? [];
final discountAmount = double.tryParse(_discountCtrl.text) ?? 0.0;
double rawTaxable = 0;
for (var item in _items) {
final weight = item.weight ?? item.quantity;
final metalAmt = weight * item.unitPrice;
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = metalAmt * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = weight * item.makingCharge;
}
rawTaxable += (metalAmt + makingAmt + item.otherCharges);
}
final effectiveTaxable = (rawTaxable - discountAmount).clamp(0.0, double.infinity);
final avgGst = _items.isNotEmpty
? (_items.map((i) => i.taxRate > 0 ? i.taxRate : 3.0).reduce((a, b) => a + b) / _items.length)
: 3.0;
final grandTotal = effectiveTaxable + (effectiveTaxable * avgGst / 100.0);
// 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;
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);
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,
makingCharges: it.makingCharge,
makingChargesType: it.makingChargesType,
);
if (it.inventoryItemId != null) {
_selectedInventoryItem = invItems.where((i) => i.id == it.inventoryItemId).firstOrNull;
}
});
});
}
}
@override
void dispose() {
_searchCtrl.dispose();
_makingChargeCtrl.dispose();
_discountCtrl.dispose();
super.dispose();
}
void _onProductSelected(Product product, ProductCategory? category, double liveCommodityRate) {
setState(() {
_selectedProduct = product;
_selectedInventoryItem = null;
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) {
setState(() {
_selectedProduct = product;
_selectedInventoryItem = item;
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) {
if (_selectedProduct == null) return;
final weight = _selectedInventoryItem?.grossWeight ?? 1.0;
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
final metalAmount = weight * unitRate;
double makingAmt = 0.0;
if (_makingChargeType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (_makingChargeType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
final taxable = metalAmount + makingAmt - discount;
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0);
final taxAmount = (taxable * gstRate) / 100.0;
final total = taxable + taxAmount;
final item = InvoiceItem(
productId: _selectedProduct!.id,
inventoryItemId: _selectedInventoryItem?.id,
productName: _selectedProduct!.name,
categoryName: category?.name,
commodityCode: category?.commodityCode,
hsnCode: category?.defaultHsn ?? _selectedProduct?.hsnCode,
sku: _selectedInventoryItem?.sku ?? _selectedProduct?.sku,
huid: _selectedInventoryItem?.huid,
description: _selectedProduct!.name,
quantity: 1.0,
weight: weight,
unitPrice: unitRate,
makingCharge: makingCharge,
makingChargesType: _makingChargeType,
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 isDark = Theme.of(context).brightness == Brightness.dark;
ProductCategory? category;
if (_selectedProduct?.categoryId != null) {
category = categories.where((c) => c.id == _selectedProduct!.categoryId).firstOrNull;
}
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 = commodityRate > 0 ? (commodityRate * purityFactor) : (_selectedProduct?.sellingPrice ?? 0.0);
final weight = _selectedInventoryItem?.grossWeight ?? 1.0;
final metalAmount = weight * effectiveUnitRate;
final makingCharge = double.tryParse(_makingChargeCtrl.text) ?? 0.0;
double makingAmt = 0.0;
if (_makingChargeType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (_makingChargeType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
final discount = double.tryParse(_discountCtrl.text) ?? 0.0;
final taxable = metalAmount + makingAmt - discount;
final gstRate = _selectedProduct?.gstRate ?? (category?.defaultGst ?? 3.0);
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 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: 'Search Product by HUID / 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(
query.isEmpty
? 'Available Stock in Inventory (${displayInventoryItems.length}):'
: 'Matched Inventory Items (${displayInventoryItems.length}):',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
if (query.isEmpty && availableItems.length > 20)
Text(
'Showing 20 newest',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500, fontStyle: FontStyle.italic),
),
],
),
const SizedBox(height: 10),
if (displayInventoryItems.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 inventory 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;
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,
isDark: isDark,
onTap: () => _onInventoryItemSelected(item, p, cat),
);
}),
] 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 (_selectedInventoryItem?.huid != null)
_buildSheetBadge('HUID: ${_selectedInventoryItem!.huid}', Colors.purple),
if (category?.commodityCode != null)
_buildSheetBadge('${category!.commodityCode} (${formatPurity(purityFactor)})', Colors.teal),
_buildSheetBadge('Purity: ${formatPurity(purityFactor)}', Colors.amber.shade800),
],
),
],
),
),
const SizedBox(height: 16),
// DISABLED FIELDS: Rounded 12px borders
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: TextFormField(
initialValue: '${weight.toStringAsFixed(3)} g',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
enabled: false,
decoration: InputDecoration(
labelText: 'Weight',
prefixIcon: const Icon(LucideIcons.scale, size: 18),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
key: ValueKey(effectiveUnitRate),
initialValue: '${effectiveUnitRate.toStringAsFixed(2)}/g',
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),
// ENABLED FIELDS: Making Charges & Type
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!),
),
),
],
),
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: 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),
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 _buildSearchItemCard({
required Product product,
ProductCategory? category,
InventoryItem? inventoryItem,
required double commodityRate,
required bool isDark,
required VoidCallback onTap,
}) {
final purity = resolvePurity(categoryPurity: category?.purityFactor, productPurity: product.purityFactor);
final unitRate = commodityRate > 0 ? (commodityRate * purity) : (product.sellingPrice ?? 0.0);
final weight = inventoryItem?.grossWeight ?? 1.0;
final metalAmount = 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 (makingType == 'PERCENTAGE') {
makingAmt = metalAmount * (makingCharge / 100.0);
} else if (makingType == 'PER_PIECE') {
makingAmt = makingCharge;
} else {
makingAmt = weight * makingCharge;
}
final gstRate = product.gstRate ?? (category?.defaultGst ?? 3.0);
final taxable = metalAmount + 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: (huid != null ? Colors.purple : Colors.blue).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
huid != null ? LucideIcons.gem : LucideIcons.tag,
size: 20,
color: huid != null ? Colors.purple : 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 (huid != null && huid.isNotEmpty)
_buildSheetBadge('HUID: $huid', Colors.purple),
_buildSheetBadge('Purity: ${formatPurity(purity)}', Colors.amber.shade900),
],
),
],
),
),
],
),
const Divider(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Rate: ₹${unitRate.toStringAsFixed(2)}/g • ${weight.toStringAsFixed(3)}g',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600, fontWeight: FontWeight.w500),
),
Text(
'Metal: ₹${metalAmount.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),
),
],
),
],
),
],
),
),
),
),
);
}
}