1445 lines
71 KiB
Dart
1445 lines
71 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 '../../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<UnitOfMeasure> uoms,
|
|
bool isCommodity = false,
|
|
}) {
|
|
if (isCommodity) {
|
|
return (category != null && category.baseUnit.isNotEmpty) ? category.baseUnit : 'g';
|
|
}
|
|
if (product?.uomId != null) {
|
|
final uom = uoms.where((u) => u.id == product!.uomId).firstOrNull;
|
|
if (uom != null && uom.abbreviation != null && uom.abbreviation!.isNotEmpty) return uom.abbreviation!;
|
|
if (uom != null && uom.name.isNotEmpty) return uom.name;
|
|
}
|
|
if (category != null && category.baseUnit.isNotEmpty) {
|
|
return category.baseUnit;
|
|
}
|
|
return 'pcs';
|
|
}
|
|
|
|
String _resolveImageUrl(String path) {
|
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
return path;
|
|
}
|
|
final base = DioClient().dio.options.baseUrl;
|
|
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
|
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
|
|
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
|
|
}
|
|
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
|
|
}
|
|
|
|
class 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)),
|
|
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<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 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<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));
|
|
|
|
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')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|