1398 lines
58 KiB
Dart
1398 lines
58 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/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,
|
|
),
|
|
),
|
|
),
|
|
if (item.hsnCode != null && item.hsnCode!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'HSN: ${item.hsnCode}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade600,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
)
|
|
else if (product?.hsnCode != null &&
|
|
product!.hsnCode!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
'HSN: ${product.hsnCode}',
|
|
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;
|
|
final hsnToDisplay = item.hsnCode ?? product?.hsnCode;
|
|
String itemDesc = item.description ?? 'Item';
|
|
if (skuToDisplay != null && skuToDisplay.isNotEmpty)
|
|
itemDesc += '\nSKU: $skuToDisplay';
|
|
if (hsnToDisplay != null && hsnToDisplay.isNotEmpty)
|
|
itemDesc += '\nHSN: $hsnToDisplay';
|
|
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')));
|
|
}
|
|
}
|
|
}
|