Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done
This commit is contained in:
@@ -0,0 +1,789 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/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 '../../inventory/providers/products_provider.dart';
|
||||
import '../domain/invoice.dart';
|
||||
import '../providers/invoices_provider.dart';
|
||||
import '../providers/customers_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import 'widgets/receive_payment_sheet.dart';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@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?.firstWhere(
|
||||
(c) => c.id == latestInvoice.customerId,
|
||||
orElse: () => null as dynamic,
|
||||
);
|
||||
|
||||
final businessState = ref.watch(businessProfileProvider);
|
||||
final business = businessState.value;
|
||||
|
||||
// Watch products so the UI rebuilds if products are loaded asynchronously
|
||||
ref.watch(productsProvider);
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: '₹');
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
|
||||
double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: Text('Invoice #${latestInvoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.share2, size: 20),
|
||||
onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Screenshot(
|
||||
controller: _screenshotController,
|
||||
child: Container(
|
||||
color: Colors.grey[50],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Biller Header Card (Premium Dark)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade900, Colors.blue.shade800],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.blue.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
business?.businessName ?? 'Your Company Name',
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
latestInvoice.status,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (business?.address != null && business!.address!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(LucideIcons.mapPin, size: 14, color: Colors.blue.shade200),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(business.address!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13, height: 1.4))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.phone, size: 14, color: Colors.blue.shade200),
|
||||
const SizedBox(width: 8),
|
||||
Text(business.contactNumber!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (business?.gstin != null && business!.gstin!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.building, size: 14, color: Colors.blue.shade200),
|
||||
const SizedBox(width: 8),
|
||||
Text('GSTIN: ${business.gstin}', style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Invoice Dates & Customer Details Row
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Dates
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('DUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 4),
|
||||
Text(latestInvoice.dueDate != null ? formatDate.format(latestInvoice.dueDate!) : '-', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Bill To
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
|
||||
const SizedBox(height: 8),
|
||||
if (customer != null) ...[
|
||||
Text(customer.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, height: 1.2)),
|
||||
const SizedBox(height: 4),
|
||||
if (customer.address != null && customer.address!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(customer.address!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.3)),
|
||||
),
|
||||
if (customer.phone != null && customer.phone!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(customer.phone!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
|
||||
),
|
||||
if (customer.gstin != null && customer.gstin!.isNotEmpty)
|
||||
Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)),
|
||||
] else ...[
|
||||
const Text('Walk-in Customer', style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic, color: Colors.grey)),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Itemized Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Item Cards instead of DataTable
|
||||
...(latestInvoice.items ?? []).map((item) {
|
||||
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||
final skuToDisplay = item.sku ?? product?.sku;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.grey.withOpacity(0.03), blurRadius: 5, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.description ?? 'Item',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatCurrency.format(item.total),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (skuToDisplay != null && skuToDisplay.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2)} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade800)),
|
||||
Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
if (item.makingCharge > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('+ Making Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
Text(formatCurrency.format(item.makingCharge), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.otherCharges > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('+ Other Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
Text(formatCurrency.format(item.otherCharges), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.discount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('- Discount', style: TextStyle(fontSize: 12, color: Colors.green.shade600)),
|
||||
Text('-${formatCurrency.format(item.discount)}', style: TextStyle(fontSize: 12, color: Colors.green.shade600, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.taxRate > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('+ Tax (${item.taxRate}%)', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
Text(formatCurrency.format(item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Summary Section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)),
|
||||
if (latestInvoice.discountTotal > 0)
|
||||
_buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green.shade700),
|
||||
if (latestInvoice.taxTotal > 0)
|
||||
_buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)),
|
||||
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Divider(height: 1, color: Colors.grey),
|
||||
),
|
||||
|
||||
_buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18, color: Colors.black),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSummaryRow('Amount Paid', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green.shade700, isBold: true),
|
||||
const SizedBox(height: 8),
|
||||
_buildSummaryRow('Balance Due', formatCurrency.format(remaining),
|
||||
color: remaining > 0 ? Colors.red.shade700 : Colors.green.shade700,
|
||||
isBold: true,
|
||||
fontSize: 16
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Payment Info
|
||||
FutureBuilder<List<InvoicePayment>>(
|
||||
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!),
|
||||
builder: (context, snapshot) {
|
||||
final payments = snapshot.data ?? [];
|
||||
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
|
||||
|
||||
if (displayMethod == null && !latestInvoice.isEmi) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (displayMethod != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.creditCard, size: 16, color: Colors.blue),
|
||||
const SizedBox(width: 8),
|
||||
Text('Payment Mode: $displayMethod', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.blue)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
|
||||
if (displayMethod != null) const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple),
|
||||
const SizedBox(width: 8),
|
||||
Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
|
||||
],
|
||||
),
|
||||
if (latestInvoice.nextPaymentDate != null && remaining > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24, top: 4),
|
||||
child: Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Bottom Spacing for FAB
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: (remaining > 0 && latestInvoice.status != 'DRAFT')
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
|
||||
icon: const Icon(LucideIcons.indianRupee),
|
||||
label: const Text('Receive Payment', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.blue,
|
||||
elevation: 4,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryRow(String label, String value, {bool isBold = false, Color? color, double fontSize = 14}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w500, color: color ?? Colors.grey.shade600)),
|
||||
Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color ?? Colors.black87)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showShareOptions(BuildContext context, String invoiceNumber) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text('Share Invoice $invoiceNumber', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.image, color: Colors.blue),
|
||||
title: const Text('Share as Image'),
|
||||
subtitle: const Text('Best for WhatsApp, precise look'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_shareAsImage(invoiceNumber);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.fileText, color: Colors.red),
|
||||
title: const Text('Share as PDF'),
|
||||
subtitle: const Text('Professional document 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).isEmpty
|
||||
? null
|
||||
: customers.firstWhere((c) => c.id == latestInvoice.customerId);
|
||||
|
||||
final businessState = ref.read(businessProfileProvider);
|
||||
final business = businessState.value;
|
||||
|
||||
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
|
||||
final formatDate = DateFormat('dd MMM yyyy');
|
||||
|
||||
// Fetch payment info
|
||||
final payments = await ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!);
|
||||
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
|
||||
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (pw.Context context) {
|
||||
return [
|
||||
// Header
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(business?.businessName ?? 'Your Company', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 4),
|
||||
if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 12)),
|
||||
if (business?.contactNumber != null) pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 12)),
|
||||
if (business?.gstin != null) pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text('INVOICE', style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Text(latestInvoice.invoiceNumber, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
|
||||
pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 12)),
|
||||
if (latestInvoice.dueDate != null)
|
||||
pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 12)),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))),
|
||||
child: pw.Text(latestInvoice.status, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 32),
|
||||
|
||||
// Bill To
|
||||
pw.Text('BILL TO:', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
|
||||
pw.SizedBox(height: 4),
|
||||
if (customer != null) ...[
|
||||
pw.Text(customer.name, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
|
||||
if (customer.address != null) pw.Text(customer.address!, style: const pw.TextStyle(fontSize: 12)),
|
||||
if (customer.phone != null) pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 12)),
|
||||
if (customer.gstin != null) pw.Text('GSTIN: ${customer.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
|
||||
] else ...[
|
||||
pw.Text('Walk-in Customer', style: const pw.TextStyle(fontSize: 14)),
|
||||
],
|
||||
|
||||
pw.SizedBox(height: 32),
|
||||
|
||||
// Items Table
|
||||
pw.TableHelper.fromTextArray(
|
||||
context: context,
|
||||
border: const pw.TableBorder(
|
||||
bottom: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5),
|
||||
),
|
||||
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white),
|
||||
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue800),
|
||||
cellAlignments: {
|
||||
0: pw.Alignment.centerLeft,
|
||||
1: pw.Alignment.centerRight,
|
||||
2: pw.Alignment.centerRight,
|
||||
3: pw.Alignment.centerRight,
|
||||
},
|
||||
data: [
|
||||
['Description', 'Qty', 'Unit Price', 'Total'],
|
||||
...(latestInvoice.items ?? []).map((item) {
|
||||
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
|
||||
final skuToDisplay = item.sku ?? product?.sku;
|
||||
String itemDesc = item.description ?? 'Item';
|
||||
if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay';
|
||||
if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}';
|
||||
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
|
||||
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
|
||||
if (item.taxRate > 0) {
|
||||
final taxAmt = item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount;
|
||||
itemDesc += '\n+ Tax (${item.taxRate}%): ${formatCurrency.format(taxAmt)}';
|
||||
}
|
||||
|
||||
return [
|
||||
itemDesc,
|
||||
item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2),
|
||||
formatCurrency.format(item.unitPrice),
|
||||
formatCurrency.format(item.total),
|
||||
];
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 24),
|
||||
|
||||
// Totals
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.end,
|
||||
children: [
|
||||
pw.Container(
|
||||
width: 250,
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Subtotal:'),
|
||||
pw.Text(formatCurrency.format(latestInvoice.subtotal)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
if (latestInvoice.discountTotal > 0) ...[
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Discount:'),
|
||||
pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(color: PdfColors.green700)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
],
|
||||
if (latestInvoice.taxTotal > 0) ...[
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Tax:'),
|
||||
pw.Text(formatCurrency.format(latestInvoice.taxTotal)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
],
|
||||
pw.Divider(color: PdfColors.grey400),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
|
||||
pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)),
|
||||
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: pw.TextStyle(color: PdfColors.green700)),
|
||||
],
|
||||
),
|
||||
pw.Divider(color: PdfColors.grey400),
|
||||
pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
|
||||
pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 24),
|
||||
|
||||
if (displayMethod != null || latestInvoice.isEmi)
|
||||
pw.Container(
|
||||
padding: const pw.EdgeInsets.all(12),
|
||||
decoration: pw.BoxDecoration(color: PdfColors.blue50, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8))),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (displayMethod != null)
|
||||
pw.Text('Payment Mode: $displayMethod', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
|
||||
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
|
||||
if (displayMethod != null) pw.SizedBox(height: 4),
|
||||
pw.Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.purple800)),
|
||||
if (latestInvoice.nextPaymentDate != null && (latestInvoice.totalAmount - latestInvoice.amountPaid) > 0)
|
||||
pw.Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 40),
|
||||
|
||||
// Footer
|
||||
pw.Divider(color: PdfColors.grey300),
|
||||
pw.SizedBox(height: 8),
|
||||
pw.Center(
|
||||
child: pw.Text('Thank you for your business!', style: pw.TextStyle(color: PdfColors.grey600, fontStyle: pw.FontStyle.italic)),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final directory = await getTemporaryDirectory();
|
||||
final pdfPath = await File('${directory.path}/Invoice_$invoiceNumber.pdf').create();
|
||||
await pdfPath.writeAsBytes(await pdf.save());
|
||||
|
||||
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Invoice $invoiceNumber');
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user