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

1271 lines
62 KiB
Dart

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 '../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';
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<InvoiceDetailsScreen> createState() => _InvoiceDetailsScreenState();
}
class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
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)),
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)),
],
),
),
),
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.id == product?.categoryId).firstOrNull;
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;
}
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 ?? 'Jewellery 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 (item.huid != null)
_buildDetailBadge('HUID: ${item.huid}', Colors.purple),
if (item.hsnCode != null)
_buildDetailBadge('HSN: ${item.hsnCode}', Colors.grey),
],
),
],
),
),
Text(
formatCurrency.format(item.total),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green),
),
],
),
const Divider(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Metal (${weight.toStringAsFixed(3)}g @ ₹${item.unitPrice.toStringAsFixed(2)}/g):',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Text(formatCurrency.format(metalAmount), style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)),
],
),
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)),
],
),
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(((metalAmount + makingChargeAmt) * (item.taxRate / 100.0)))}',
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<void> _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<void> _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 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<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> 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));
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),
),
),
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 (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: [
'Item / HUID',
'Weight / Qty',
'Rate',
'Making Chg',
'Other Chg',
'Total Amt',
],
data: 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)} g'
: '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs';
final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ 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 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(
'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}',
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')),
);
}
}
}
}