import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:intl/intl.dart'; import 'package:screenshot/screenshot.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import '../../inventory/providers/products_provider.dart'; import '../../inventory/providers/product_categories_provider.dart'; import '../../inventory/providers/uoms_provider.dart'; import '../../inventory/domain/product.dart'; import '../domain/invoice.dart'; import '../providers/invoices_provider.dart'; import '../providers/customers_provider.dart'; import '../../business/providers/business_provider.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../../core/network/dio_client.dart'; import 'widgets/receive_payment_sheet.dart'; import 'invoice_builder_screen.dart'; bool _isCommodityProduct({ ProductCategory? category, Product? product, InvoiceItem? invoiceItem, String? commodityCode, String? huid, }) { final h = huid ?? invoiceItem?.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 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 InvoiceDetailsScreen extends ConsumerStatefulWidget { final Invoice invoice; const InvoiceDetailsScreen({super.key, required this.invoice}); @override ConsumerState createState() => _InvoiceDetailsScreenState(); } class _InvoiceDetailsScreenState extends ConsumerState { final ScreenshotController _screenshotController = ScreenshotController(); void _showReceivePaymentSheet(BuildContext context, WidgetRef ref, Invoice latestInvoice) async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => ReceivePaymentSheet(invoice: latestInvoice), ); if (result == true) { ref.read(invoicesProvider.notifier).refresh(); } } void _editInvoice(Invoice invoice) { Navigator.push( context, MaterialPageRoute( builder: (_) => InvoiceBuilderScreen(existingInvoice: invoice), ), ); } @override Widget build(BuildContext context) { final invoicesState = ref.watch(invoicesProvider); final latestInvoice = invoicesState.value?.firstWhere( (i) => i.id == widget.invoice.id, orElse: () => widget.invoice, ) ?? widget.invoice; final customersState = ref.watch(customersProvider); final customer = customersState.value?.where((c) => c.id == latestInvoice.customerId).firstOrNull; final businessState = ref.watch(businessProfileProvider); final business = businessState.value; final products = ref.watch(productsProvider).value ?? []; final categories = ref.watch(productCategoriesProvider).value ?? []; final isDark = Theme.of(context).brightness == Brightness.dark; final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('dd MMM yyyy'); double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid; return Scaffold( backgroundColor: isDark ? const Color(0xFF0F172A) : Colors.grey[50], appBar: AppBar( title: Text( 'Invoice #${latestInvoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), centerTitle: true, actions: [ IconButton( icon: const Icon(LucideIcons.edit3, size: 20), tooltip: 'Edit Invoice', onPressed: () => _editInvoice(latestInvoice), ), IconButton( icon: const Icon(LucideIcons.share2, size: 20), tooltip: 'Share Invoice', onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber), ), ], ), body: SingleChildScrollView( child: Screenshot( controller: _screenshotController, child: Container( color: isDark ? const Color(0xFF0F172A) : Colors.grey[50], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Top Header Card: Business info Container( 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), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( business?.businessName ?? 'KIFI JEWELLERS', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), if (business?.address != null) Text(business!.address!, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), if (business?.gstin != null) Text('GSTIN: ${business!.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blue)), ], ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: _getStatusColor(_getEffectiveStatus(latestInvoice)).withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Text( _getEffectiveStatus(latestInvoice), style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: _getStatusColor(_getEffectiveStatus(latestInvoice)), ), ), ), ], ), ), const SizedBox(height: 12), // Invoice metadata & Customer details Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Invoice metadata Expanded( flex: 2, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 2), Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.blue)), if (latestInvoice.salesChannel != null && latestInvoice.salesChannel!.isNotEmpty) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.blue.withValues(alpha: 0.25)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(LucideIcons.shoppingBag, size: 10, color: Colors.blue), const SizedBox(width: 4), Text( latestInvoice.salesChannel!, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.blue), ), ], ), ), ], if (latestInvoice.marketplaceOrderId != null && latestInvoice.marketplaceOrderId!.isNotEmpty) ...[ const SizedBox(height: 4), Text( 'Ref: ${latestInvoice.marketplaceOrderId}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Colors.grey.shade700), ), ], const SizedBox(height: 10), const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 2), Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), if (latestInvoice.placeOfSupply != null && latestInvoice.placeOfSupply!.isNotEmpty) ...[ const SizedBox(height: 8), const Text('PLACE OF SUPPLY', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 2), Text(latestInvoice.placeOfSupply!, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), ], ], ), ), ), const SizedBox(width: 12), // Customer Details Expanded( flex: 3, child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey)), const SizedBox(height: 4), if (customer != null) ...[ Text(customer.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), if (customer.phone != null) Text('Ph: ${customer.phone}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), if (customer.gstin != null) Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.blue)), ] else ...[ const Text('Walk-in Customer', style: TextStyle(fontSize: 13, fontStyle: FontStyle.italic, color: Colors.grey)), ], ], ), ), ), ], ), const SizedBox(height: 20), // Itemized Details Header const Text('Itemized Particulars', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 10), // Item Cards ...(latestInvoice.items).map((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; final uoms = ref.watch(uomsProvider).value ?? []; 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; final unit = _resolveProductUnit(product: product, category: category, uoms: uoms, isCommodity: isItemCommodity); 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; 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), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (item.photoUrl != null && item.photoUrl!.isNotEmpty) Container( width: 44, height: 44, 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 ?? product?.name ?? item.description ?? 'Item', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), ), const SizedBox(height: 4), Wrap( spacing: 6, runSpacing: 4, children: [ if (item.categoryName != null || category?.name != null) _buildDetailBadge(item.categoryName ?? category!.name, const Color(0xFFD4AF37)), if (item.sku != null || product?.sku != null) _buildDetailBadge('SKU: ${item.sku ?? product!.sku}', Colors.blue), if (isItemCommodity && item.huid != null && item.huid!.isNotEmpty) _buildDetailBadge('HUID: ${item.huid}', Colors.purple), if (item.hsnCode != null && item.hsnCode!.isNotEmpty) _buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey), if (!isItemCommodity) _buildDetailBadge('Unit: $unit', Colors.teal), ], ), ], ), ), Text( formatCurrency.format(item.total), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green), ), ], ), const Divider(height: 18), if (isItemCommodity) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Metal (${qtyOrWeight.toStringAsFixed(3)}$unit @ ₹${item.unitPrice.toStringAsFixed(2)}/$unit):', style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), ], ), 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: 12, color: Colors.grey.shade600), ), Text(formatCurrency.format(makingChargeAmt), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12, 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: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w500), ), Text(formatCurrency.format(baseAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), ], ), ], if (item.discount > 0) ...[ const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Discount:', style: TextStyle(fontSize: 11, color: Colors.red)), Text('- ${formatCurrency.format(item.discount)}', style: const TextStyle(fontSize: 11, 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: 11, color: Colors.grey.shade500)), Text( '+ ${formatCurrency.format(taxAmount)}', style: TextStyle(fontSize: 11, color: Colors.grey.shade600), ), ], ), ], ], ), ); }), const SizedBox(height: 12), // Grand Totals Summary Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: isDark ? const Color(0xFF1E293B) : Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: isDark ? Colors.white12 : Colors.grey.shade200), ), child: Column( children: [ _buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)), if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[ _buildSummaryRow('CGST', formatCurrency.format(latestInvoice.cgstTotal)), _buildSummaryRow('SGST', formatCurrency.format(latestInvoice.sgstTotal)), ] else if (latestInvoice.igstTotal > 0) ...[ _buildSummaryRow('IGST', formatCurrency.format(latestInvoice.igstTotal)), ] else if (latestInvoice.taxTotal > 0) ...[ _buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)), ], if (latestInvoice.discountTotal > 0) _buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green), const Divider(height: 20), _buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18), const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: isDark ? Colors.black26 : Colors.grey.shade100, borderRadius: BorderRadius.circular(10), ), child: Column( children: [ _buildSummaryRow('Amount Received', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green, isBold: true), const SizedBox(height: 6), _buildSummaryRow('Balance Due', formatCurrency.format(remaining), color: remaining > 0 ? Colors.red : Colors.green, isBold: true, fontSize: 15), ], ), ), ], ), ), const SizedBox(height: 20), // Attached Invoice Bill if available if (latestInvoice.invoiceUrl != null && latestInvoice.invoiceUrl!.isNotEmpty) ...[ const Text('Attached Document', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), ListTile( tileColor: isDark ? const Color(0xFF1E293B) : Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), leading: const Icon(LucideIcons.image, color: Colors.blue), title: const Text('View Attached Sales Slip'), subtitle: const Text('Tap to open photo gallery', style: TextStyle(fontSize: 12, color: Colors.blue)), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => AttachmentGalleryScreen( images: [NetworkImage(_resolveImageUrl(latestInvoice.invoiceUrl!))], initialIndex: 0, ), ), ); }, ), const SizedBox(height: 20), ], // Bottom Action: Receive Payment if balance due if (remaining > 0) ElevatedButton.icon( onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), icon: const Icon(LucideIcons.plusCircle, size: 18), label: const Text('Record Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Colors.green, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), const SizedBox(height: 30), ], ), ), ), ), ); } Widget _buildDetailBadge(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(4), 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 _buildSummaryRow(String label, String value, {bool isBold = false, double fontSize = 14, Color? color}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.normal)), Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color)), ], ), ); } String _getEffectiveStatus(Invoice invoice) { final balanceDue = invoice.totalAmount - invoice.amountPaid; if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) { return 'PAID'; } if (invoice.amountPaid > 0.01) { return 'PARTIAL'; } return invoice.status.isNotEmpty ? invoice.status : 'DRAFT'; } Color _getStatusColor(String status) { switch (status) { case 'PAID': return Colors.green; case 'PARTIAL': return Colors.blue; case 'FINALIZED': return Colors.orange; case 'DRAFT': default: return Colors.grey; } } void _showShareOptions(BuildContext context, String invoiceNumber) { showModalBottomSheet( context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Padding( padding: EdgeInsets.all(16), child: Text('Share Invoice', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ), ListTile( leading: const Icon(LucideIcons.image, color: Colors.blue), title: const Text('Share as Image'), subtitle: const Text('High-resolution receipt preview'), onTap: () { Navigator.pop(ctx); _shareAsImage(invoiceNumber); }, ), ListTile( leading: const Icon(LucideIcons.fileText, color: Colors.red), title: const Text('Share as PDF (Tax Invoice)'), subtitle: const Text('Official jewellery tax invoice format'), onTap: () { Navigator.pop(ctx); _shareAsPdf(invoiceNumber); }, ), const SizedBox(height: 16), ], ), ), ); } Future _shareAsImage(String invoiceNumber) async { try { final Uint8List? image = await _screenshotController.capture(pixelRatio: 3.0); if (image == null) return; final directory = await getTemporaryDirectory(); final imagePath = await File('${directory.path}/Invoice_$invoiceNumber.png').create(); await imagePath.writeAsBytes(image); await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber'); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); } } Future _shareAsPdf(String invoiceNumber) async { try { final invoicesState = ref.read(invoicesProvider); final latestInvoice = invoicesState.value?.firstWhere( (i) => i.id == widget.invoice.id, orElse: () => widget.invoice, ) ?? widget.invoice; final customers = ref.read(customersProvider).value ?? []; final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull; final business = ref.read(businessProfileProvider).value; final products = ref.read(productsProvider).value ?? []; final categories = ref.read(productCategoriesProvider).value ?? []; final uoms = ref.read(uomsProvider).value ?? []; final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); final formatDate = DateFormat('dd MMM yyyy'); final font = await PdfGoogleFonts.robotoRegular(); final boldFont = await PdfGoogleFonts.robotoBold(); final businessStateId = business?.stateId; final customerStateId = customer?.stateId; final isSameState = businessStateId != null && customerStateId != null ? (businessStateId == customerStateId) : true; // Group items by product ID if available, or itemize final Map> groupedItems = {}; final List ungroupedItems = []; for (final item in latestInvoice.items) { if (item.productId != null) { groupedItems.putIfAbsent(item.productId!, () => []).add(item); } else { ungroupedItems.add(item); } } final pdf = pw.Document(); pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, theme: pw.ThemeData.withFont( base: font, bold: boldFont, ), margin: const pw.EdgeInsets.all(28), build: (pw.Context context) { return [ // 1. Header with Business Details & TAX INVOICE title pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( business?.businessName ?? 'KIFI JEWELLERS', style: pw.TextStyle( fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), pw.SizedBox(height: 4), if (business?.address != null && business!.address!.isNotEmpty) pw.Text( business.address!, style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty) pw.Text( 'Phone: ${business.contactNumber}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), if (business?.gstin != null && business!.gstin!.isNotEmpty) pw.Text( 'GSTIN: ${business.gstin}', style: pw.TextStyle( fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800, ), ), ], ), ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text( 'TAX INVOICE', style: pw.TextStyle( fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), pw.SizedBox(height: 6), pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: pw.BoxDecoration( color: _getEffectiveStatus(latestInvoice) == 'PAID' ? PdfColors.green100 : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)), border: pw.Border.all( color: _getEffectiveStatus(latestInvoice) == 'PAID' ? PdfColors.green700 : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500), width: 0.5, ), ), child: pw.Text( _getEffectiveStatus(latestInvoice), style: pw.TextStyle( fontSize: 9, fontWeight: pw.FontWeight.bold, color: _getEffectiveStatus(latestInvoice) == 'PAID' ? PdfColors.green900 : (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800), ), ), ), pw.SizedBox(height: 6), pw.Text( 'Invoice #: ${latestInvoice.invoiceNumber}', style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), ), pw.Text( 'Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), if (latestInvoice.dueDate != null) pw.Text( 'Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700), ), ], ), ], ), pw.SizedBox(height: 18), // 2. Bill To Box (Customer Details) pw.Container( padding: const pw.EdgeInsets.all(10), decoration: pw.BoxDecoration( color: PdfColors.grey100, border: pw.Border.all(color: PdfColors.grey400, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), ), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( 'BILL TO (CUSTOMER):', style: pw.TextStyle( fontSize: 8.5, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700, ), ), pw.SizedBox(height: 2), pw.Text( customer?.name ?? 'Walk-in Customer', style: pw.TextStyle( fontSize: 12, fontWeight: pw.FontWeight.bold, ), ), if (customer?.phone != null && customer!.phone!.isNotEmpty) pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 9)), if (customer?.address != null && customer!.address!.isNotEmpty) pw.Text('Address: ${customer.address}', style: const pw.TextStyle(fontSize: 9)), if (customer?.gstin != null && customer!.gstin!.isNotEmpty) pw.Text( 'GSTIN: ${customer.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), ), ], ), ), ], ), ), pw.SizedBox(height: 16), // 3. Product Groups with Full Details (Category, Purity, HSN, HUID, Weight, Making Charges, GST Breakup) ...groupedItems.entries.map((entry) { final productId = entry.key; final group = entry.value; final product = products.where((p) => p.id == productId).firstOrNull; final category = categories.where((c) => c.id == product?.categoryId).firstOrNull; final productName = product?.name ?? group.first.productName ?? 'Item $productId'; final categoryName = category?.name ?? group.first.categoryName ?? 'General Category'; final skuStr = product?.sku ?? group.first.sku; final purityStr = product?.purityFactor != null ? '${(product!.purityFactor! * 100).toStringAsFixed(1)}%' : (category?.purityFactor != null ? '${(category!.purityFactor! * 100).toStringAsFixed(1)}%' : '0.916'); final hsnStr = product?.hsnCode ?? group.first.hsnCode ?? category?.defaultHsn ?? '-'; double groupSubtotal = 0; double groupMaking = 0; double groupTax = 0; for (final item in group) { 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; } groupSubtotal += metalAmt; groupMaking += makingAmt; groupTax += (item.cgst + item.sgst + item.igst); } final groupTotal = group.fold(0.0, (sum, i) => sum + i.total); final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0)); final isGroupCommodity = group.any((item) => _isCommodityProduct( category: category, product: product, invoiceItem: item, )); final groupUnit = _resolveProductUnit( product: product, category: category, uoms: uoms, isCommodity: isGroupCommodity, ); return pw.Container( margin: const pw.EdgeInsets.only(bottom: 14), decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.grey300, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), ), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ // Product Title & Badges pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: const pw.BoxDecoration( color: PdfColors.grey200, borderRadius: pw.BorderRadius.vertical(top: pw.Radius.circular(5.5)), ), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.Expanded( child: pw.Text( productName, style: pw.TextStyle( fontSize: 11, fontWeight: pw.FontWeight.bold, color: PdfColors.black, ), ), ), pw.Wrap( spacing: 6, runSpacing: 4, children: [ pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: pw.BoxDecoration( color: PdfColors.orange50, border: pw.Border.all(color: PdfColors.orange400, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), ), child: pw.Text( categoryName, style: pw.TextStyle( fontSize: 7.5, fontWeight: pw.FontWeight.bold, color: PdfColors.deepOrange900, ), ), ), if (skuStr != null && skuStr.isNotEmpty) pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: pw.BoxDecoration( color: PdfColors.grey100, border: pw.Border.all(color: PdfColors.grey500, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), ), child: pw.Text( 'SKU: $skuStr', style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800), ), ), if (isGroupCommodity) pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: pw.BoxDecoration( color: PdfColors.amber50, border: pw.Border.all(color: PdfColors.amber600, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), ), child: pw.Text( 'Purity: $purityStr', style: pw.TextStyle( fontSize: 7.5, fontWeight: pw.FontWeight.bold, color: PdfColors.brown800, ), ), ), if (!isGroupCommodity) pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: pw.BoxDecoration( color: PdfColors.teal50, border: pw.Border.all(color: PdfColors.teal400, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), ), child: pw.Text( 'Unit: $groupUnit', style: pw.TextStyle( fontSize: 7.5, fontWeight: pw.FontWeight.bold, color: PdfColors.teal900, ), ), ), if (hsnStr != '-') pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: pw.BoxDecoration( color: PdfColors.purple50, border: pw.Border.all(color: PdfColors.purple400, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)), ), child: pw.Text( 'HSN: $hsnStr', style: pw.TextStyle( fontSize: 7.5, fontWeight: pw.FontWeight.bold, color: PdfColors.purple900, ), ), ), ], ), ], ), ), // Items Table for this Product pw.TableHelper.fromTextArray( context: context, border: const pw.TableBorder( bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5), ), headerStyle: pw.TextStyle( fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8, ), headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900), cellStyle: const pw.TextStyle(fontSize: 8), cellAlignments: { 0: pw.Alignment.centerLeft, 1: pw.Alignment.centerRight, 2: pw.Alignment.centerRight, 3: pw.Alignment.centerRight, 4: pw.Alignment.centerRight, 5: pw.Alignment.centerRight, }, headers: isGroupCommodity ? [ 'Item / HUID', 'Weight / Qty', 'Rate', 'Making Chg', 'Other Chg', 'Total Amt', ] : [ 'Item / SKU', 'Qty', 'Unit Price', 'Discount', 'GST %', 'Total Amt', ], data: isGroupCommodity ? group.map((item) { String particular = ''; if (item.huid != null && item.huid!.isNotEmpty) { particular = 'HUID: ${item.huid}'; } else if (item.sku != null && item.sku!.isNotEmpty) { particular = 'SKU: ${item.sku}'; } else { particular = 'Standard Item'; } final weight = item.weight ?? item.quantity; final weightQty = item.weight != null && item.weight! > 0 ? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} $groupUnit' : '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs'; final rateUnit = item.weight != null && item.weight! > 0 ? '/ $groupUnit' : '/ pc'; double makingAmt = 0.0; if (item.makingChargesType == 'PERCENTAGE') { makingAmt = (weight * item.unitPrice) * (item.makingCharge / 100.0); } else if (item.makingChargesType == 'PER_PIECE') { makingAmt = item.makingCharge; } else { makingAmt = weight * item.makingCharge; } return [ particular, weightQty, '${formatCurrency.format(item.unitPrice)} $rateUnit', makingAmt > 0 ? formatCurrency.format(makingAmt) : '-', item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', formatCurrency.format(item.total), ]; }).toList() : group.map((item) { final qty = item.weight != null && item.weight! > 0 ? item.weight! : (item.quantity > 0 ? item.quantity : 1.0); final qtyStr = '${qty.toStringAsFixed(qty.truncateToDouble() == qty ? 0 : 2)} $groupUnit'; return [ item.sku ?? skuStr ?? 'Item', qtyStr, formatCurrency.format(item.unitPrice), item.discount > 0 ? '-${formatCurrency.format(item.discount)}' : '-', '${item.taxRate.toStringAsFixed(1)}%', formatCurrency.format(item.total), ]; }).toList(), ), // Group Subtotal & GST Calculation Box pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: const pw.BoxDecoration( color: PdfColors.blue50, borderRadius: pw.BorderRadius.vertical(bottom: pw.Radius.circular(5.5)), ), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( isGroupCommodity ? 'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}' : 'Items Subtotal: ${formatCurrency.format(groupSubtotal)}', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), pw.Text( 'Taxable: ${formatCurrency.format(groupSubtotal + groupMaking)}', style: const pw.TextStyle(fontSize: 8), ), ], ), if (gstRate > 0) ...[ pw.SizedBox(height: 2), if (isSameState) ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'CGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), pw.Text( '+ ${formatCurrency.format(groupTax / 2)}', style: const pw.TextStyle(fontSize: 8), ), ], ), pw.SizedBox(height: 2), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'SGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), pw.Text( '+ ${formatCurrency.format(groupTax / 2)}', style: const pw.TextStyle(fontSize: 8), ), ], ), ] else ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'IGST (${gstRate.toStringAsFixed(1)}%):', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), pw.Text( '+ ${formatCurrency.format(groupTax)}', style: const pw.TextStyle(fontSize: 8), ), ], ), ], ], pw.Divider(color: PdfColors.grey300, height: 8), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'Group Total:', style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), pw.Text( formatCurrency.format(groupTotal), style: pw.TextStyle( fontSize: 8.5, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), ], ), ], ), ), ], ), ); }), // 3b. Ungrouped Items (if any) if (ungroupedItems.isNotEmpty) ...[ pw.TableHelper.fromTextArray( context: context, border: const pw.TableBorder( bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5), ), headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8), headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900), cellStyle: const pw.TextStyle(fontSize: 8), headers: ['Particulars', 'Weight / Qty', 'Rate', 'Making Chg', 'Other Chg', 'Total'], data: ungroupedItems.map((item) { final weightQty = item.weight != null && item.weight! > 0 ? '${item.weight!.toStringAsFixed(3)} g' : '${item.quantity.toInt()} pcs'; return [ item.productName ?? item.description ?? 'Item', weightQty, formatCurrency.format(item.unitPrice), item.makingCharge > 0 ? formatCurrency.format(item.makingCharge) : '-', item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-', formatCurrency.format(item.total), ]; }).toList(), ), pw.SizedBox(height: 12), ], pw.SizedBox(height: 12), // 4. Overall Invoice Summary pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ // Left Side: Notes & Declarations pw.Expanded( flex: 3, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ if (latestInvoice.notes != null && latestInvoice.notes!.isNotEmpty) ...[ pw.Text( 'Notes:', style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), pw.Text( latestInvoice.notes!, style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey800), ), pw.SizedBox(height: 8), ], pw.Text( 'Terms & Conditions:', style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold), ), pw.SizedBox(height: 2), pw.Text( '1. Goods once sold are subject to standard hallmarking certification.', style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700), ), pw.Text( '2. Weight & purity tested under industry standard electronic balance.', style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700), ), ], ), ), pw.SizedBox(width: 20), // Right Side: Grand Total & Tax Summary Box pw.Expanded( flex: 2, child: pw.Container( padding: const pw.EdgeInsets.all(10), decoration: pw.BoxDecoration( color: PdfColors.grey100, border: pw.Border.all(color: PdfColors.grey400, width: 0.5), borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), ), child: pw.Column( children: [ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 8.5)), pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 8.5)), ], ), if (latestInvoice.cgstTotal > 0 || latestInvoice.sgstTotal > 0 || latestInvoice.igstTotal > 0 || latestInvoice.taxTotal > 0) ...[ pw.SizedBox(height: 4), if (isSameState) ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('CGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), pw.Text('+ ${formatCurrency.format(latestInvoice.cgstTotal > 0 ? latestInvoice.cgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)), ], ), pw.SizedBox(height: 2), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('SGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), pw.Text('+ ${formatCurrency.format(latestInvoice.sgstTotal > 0 ? latestInvoice.sgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)), ], ), ] else ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('IGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)), pw.Text('+ ${formatCurrency.format(latestInvoice.igstTotal > 0 ? latestInvoice.igstTotal : latestInvoice.taxTotal)}', style: const pw.TextStyle(fontSize: 8.5)), ], ), ], ], if (latestInvoice.discountTotal > 0) ...[ pw.SizedBox(height: 4), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Discount:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), pw.Text('- ${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)), ], ), ], pw.Divider(color: PdfColors.grey400, height: 10), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'Grand Total:', style: pw.TextStyle( fontSize: 11, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), pw.Text( formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle( fontSize: 11, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900, ), ), ], ), if (latestInvoice.amountPaid > 0) ...[ pw.SizedBox(height: 6), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)), pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)), ], ), pw.SizedBox(height: 2), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'Balance Due:', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800), ), pw.Text( formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800), ), ], ), ], ], ), ), ), ], ), pw.SizedBox(height: 24), // 5. Signatures pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.SizedBox(height: 25), pw.Text( 'Customer\'s Signature', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), ], ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.SizedBox(height: 25), pw.Text( 'For ${business?.businessName ?? "KIFI JEWELLERS"} (Authorized Signatory)', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700), ), ], ), ], ), ]; }, ), ); final pdfBytes = await pdf.save(); // Cross-platform PDF sharing / printing await Printing.sharePdf( bytes: pdfBytes, filename: 'TaxInvoice_${invoiceNumber.replaceAll('/', '_')}.pdf', ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error sharing PDF: $e')), ); } } } }