Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done

This commit is contained in:
2026-08-20 20:35:41 +05:30
parent ba9a9fd8a4
commit 5f620022f3
101 changed files with 9091 additions and 240 deletions

View File

@@ -0,0 +1,72 @@
class Customer {
final int? id;
final int? userId;
final String name;
final String? email;
final String? phone;
final String? address;
final String? gstin;
final String? idNumber;
final int? stateId;
final String? photoUrl;
final DateTime? createdAt;
// New fields
final String? fatherName;
final String? gender;
final int? age;
Customer({
this.id,
this.userId,
required this.name,
this.email,
this.phone,
this.address,
this.gstin,
this.idNumber,
this.stateId,
this.photoUrl,
this.createdAt,
this.fatherName,
this.gender,
this.age,
});
factory Customer.fromJson(Map<String, dynamic> json) {
return Customer(
id: json['id'],
userId: json['userId'],
name: json['name'],
email: json['email'],
phone: json['phone'],
address: json['address'],
gstin: json['gstin'],
idNumber: json['idNumber'],
stateId: json['stateId'],
photoUrl: json['photoUrl'],
fatherName: json['fatherName'],
gender: json['gender'],
age: json['age'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (userId != null) data['userId'] = userId;
data['name'] = name;
if (email != null) data['email'] = email;
if (phone != null) data['phone'] = phone;
if (address != null) data['address'] = address;
if (gstin != null) data['gstin'] = gstin;
if (idNumber != null) data['idNumber'] = idNumber;
if (stateId != null) data['stateId'] = stateId;
if (photoUrl != null) data['photoUrl'] = photoUrl;
if (fatherName != null) data['fatherName'] = fatherName;
if (gender != null) data['gender'] = gender;
if (age != null) data['age'] = age;
return data;
}
}

View File

@@ -0,0 +1,237 @@
class InvoiceItem {
final int? id;
final int? invoiceId;
final int? productId;
final String? sku;
final String? description;
final double quantity;
final double unitPrice;
final double taxRate;
final double discount;
final double makingCharge;
final double otherCharges;
final double total;
InvoiceItem({
this.id,
this.invoiceId,
this.productId,
this.sku,
this.description,
required this.quantity,
required this.unitPrice,
this.taxRate = 0.0,
this.discount = 0.0,
this.makingCharge = 0.0,
this.otherCharges = 0.0,
required this.total,
});
InvoiceItem copyWith({
int? id,
int? invoiceId,
int? productId,
String? sku,
String? description,
double? quantity,
double? unitPrice,
double? taxRate,
double? discount,
double? makingCharge,
double? otherCharges,
double? total,
}) {
return InvoiceItem(
id: id ?? this.id,
invoiceId: invoiceId ?? this.invoiceId,
productId: productId ?? this.productId,
sku: sku ?? this.sku,
description: description ?? this.description,
quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice,
taxRate: taxRate ?? this.taxRate,
discount: discount ?? this.discount,
makingCharge: makingCharge ?? this.makingCharge,
otherCharges: otherCharges ?? this.otherCharges,
total: total ?? this.total,
);
}
factory InvoiceItem.fromJson(Map<String, dynamic> json) {
return InvoiceItem(
id: json['id'],
invoiceId: json['invoiceId'],
productId: json['productId'],
sku: json['sku'],
description: json['description'],
quantity: json['quantity'].toDouble(),
unitPrice: json['unitPrice'].toDouble(),
taxRate: json['taxRate']?.toDouble() ?? 0.0,
discount: json['discount']?.toDouble() ?? 0.0,
makingCharge: json['makingCharge']?.toDouble() ?? 0.0,
otherCharges: json['otherCharges']?.toDouble() ?? 0.0,
total: json['total'].toDouble(),
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (invoiceId != null) data['invoiceId'] = invoiceId;
if (productId != null) data['productId'] = productId;
if (sku != null) data['sku'] = sku;
if (description != null) data['description'] = description;
data['quantity'] = quantity;
data['unitPrice'] = unitPrice;
data['taxRate'] = taxRate;
data['discount'] = discount;
data['makingCharge'] = makingCharge;
data['otherCharges'] = otherCharges;
data['total'] = total;
return data;
}
}
class Invoice {
final int? id;
final int? customerId;
final String invoiceNumber;
final DateTime issueDate;
final DateTime? dueDate;
final double subtotal;
final double taxTotal;
final double discountTotal;
final double totalAmount;
final double amountPaid;
final DateTime? nextPaymentDate;
final String? paymentMethod;
final int? paymentWalletId;
final String status;
final String? notes;
final bool isEmi;
final double? emiAmount;
final String? emiCycle;
final DateTime? emiStartDate;
final List<InvoiceItem> items;
final List<InvoicePayment>? payments;
Invoice({
this.id,
this.customerId,
required this.invoiceNumber,
required this.issueDate,
this.dueDate,
required this.subtotal,
this.taxTotal = 0.0,
this.discountTotal = 0.0,
required this.totalAmount,
this.amountPaid = 0.0,
this.nextPaymentDate,
this.paymentMethod,
this.paymentWalletId,
this.status = 'DRAFT',
this.notes,
this.isEmi = false,
this.emiAmount,
this.emiCycle,
this.emiStartDate,
this.items = const [],
this.payments,
});
factory Invoice.fromJson(Map<String, dynamic> json) {
return Invoice(
id: json['id'],
customerId: json['customerId'],
invoiceNumber: json['invoiceNumber'],
issueDate: DateTime.parse(json['issueDate']),
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
subtotal: json['subtotal'].toDouble(),
taxTotal: json['taxTotal']?.toDouble() ?? 0.0,
discountTotal: json['discountTotal']?.toDouble() ?? 0.0,
totalAmount: json['totalAmount'].toDouble(),
amountPaid: json['amountPaid']?.toDouble() ?? 0.0,
nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null,
paymentMethod: json['paymentMethod'],
paymentWalletId: json['paymentWalletId'],
status: json['status'] ?? 'DRAFT',
notes: json['notes'],
isEmi: json['isEmi'] ?? false,
emiAmount: json['emiAmount']?.toDouble(),
emiCycle: json['emiCycle'],
emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) : null,
items: json['items'] != null ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() : [],
payments: json['payments'] != null ? (json['payments'] as List).map((i) => InvoicePayment.fromJson(i)).toList() : null,
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (customerId != null) data['customerId'] = customerId;
data['invoiceNumber'] = invoiceNumber;
data['issueDate'] = issueDate.toIso8601String().split('T')[0];
if (dueDate != null) data['dueDate'] = dueDate!.toIso8601String().split('T')[0];
data['subtotal'] = subtotal;
data['taxTotal'] = taxTotal;
data['discountTotal'] = discountTotal;
data['totalAmount'] = totalAmount;
if (amountPaid > 0) data['amountPaid'] = amountPaid;
if (nextPaymentDate != null) data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split('T')[0];
if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId;
data['status'] = status;
if (notes != null) data['notes'] = notes;
data['isEmi'] = isEmi;
if (emiAmount != null) data['emiAmount'] = emiAmount;
if (emiCycle != null) data['emiCycle'] = emiCycle;
if (emiStartDate != null) data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
data['items'] = items.map((i) => i.toJson()).toList();
if (payments != null) data['payments'] = payments!.map((i) => i.toJson()).toList();
return data;
}
}
class InvoicePayment {
final int? id;
final int? invoiceId;
final double amount;
final DateTime? paymentDate;
final String paymentMethod;
final int? emiInstallmentNumber;
final int? walletId;
InvoicePayment({
this.id,
this.invoiceId,
required this.amount,
this.paymentDate,
required this.paymentMethod,
this.emiInstallmentNumber,
this.walletId,
});
factory InvoicePayment.fromJson(Map<String, dynamic> json) {
return InvoicePayment(
id: json['id'],
invoiceId: json['invoiceId'],
amount: json['amount'].toDouble(),
paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null,
paymentMethod: json['paymentMethod'] ?? 'Cash',
emiInstallmentNumber: json['emiInstallmentNumber'],
walletId: json['walletId'],
);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (id != null) data['id'] = id;
if (invoiceId != null) data['invoiceId'] = invoiceId;
data['amount'] = amount;
if (paymentDate != null) data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0];
data['paymentMethod'] = paymentMethod;
if (emiInstallmentNumber != null) data['emiInstallmentNumber'] = emiInstallmentNumber;
if (walletId != null) data['walletId'] = walletId;
return data;
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/customers_provider.dart';
import '../domain/customer.dart';
import 'dart:async';
import 'dart:io';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/dio_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart';
import '../../business/providers/indian_states_provider.dart';
class AddCustomerSheet extends ConsumerStatefulWidget {
final Customer? customer;
const AddCustomerSheet({super.key, this.customer});
@override
ConsumerState<AddCustomerSheet> createState() => _AddCustomerSheetState();
}
class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _phoneCtrl;
late TextEditingController _emailCtrl;
late TextEditingController _addressCtrl;
late TextEditingController _gstinCtrl;
late TextEditingController _idNumberCtrl;
late TextEditingController _fatherNameCtrl;
late TextEditingController _ageCtrl;
String? _selectedGender;
int? _selectedStateId;
XFile? _photo;
String? _token;
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameCtrl = TextEditingController(text: widget.customer?.name ?? '');
_phoneCtrl = TextEditingController(text: widget.customer?.phone ?? '');
_emailCtrl = TextEditingController(text: widget.customer?.email ?? '');
_addressCtrl = TextEditingController(text: widget.customer?.address ?? '');
_gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? '');
_idNumberCtrl = TextEditingController(text: widget.customer?.idNumber ?? '');
_fatherNameCtrl = TextEditingController(text: widget.customer?.fatherName ?? '');
_ageCtrl = TextEditingController(text: widget.customer?.age?.toString() ?? '');
_selectedGender = widget.customer?.gender;
_selectedStateId = widget.customer?.stateId;
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
if (mounted) setState(() => _token = val);
});
}
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_emailCtrl.dispose();
_addressCtrl.dispose();
_gstinCtrl.dispose();
_idNumberCtrl.dispose();
_fatherNameCtrl.dispose();
_ageCtrl.dispose();
super.dispose();
}
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 80);
if (picked != null) {
setState(() => _photo = picked);
}
}
void _showImagePickerModal() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take a photo'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from gallery'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
],
),
),
);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final customer = Customer(
id: widget.customer?.id,
name: _nameCtrl.text,
phone: _phoneCtrl.text.isNotEmpty ? _phoneCtrl.text : null,
email: _emailCtrl.text.isNotEmpty ? _emailCtrl.text : null,
address: _addressCtrl.text.isNotEmpty ? _addressCtrl.text : null,
gstin: _gstinCtrl.text.isNotEmpty ? _gstinCtrl.text : null,
idNumber: _idNumberCtrl.text.isNotEmpty ? _idNumberCtrl.text : null,
fatherName: _fatherNameCtrl.text.isNotEmpty ? _fatherNameCtrl.text : null,
age: int.tryParse(_ageCtrl.text),
gender: _selectedGender,
stateId: _selectedStateId,
);
try {
if (widget.customer == null) {
await ref.read(customersProvider.notifier).addCustomer(customer, photo: _photo);
} else {
await ref.read(customersProvider.notifier).updateCustomer(widget.customer!.id!, customer, photo: _photo);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(widget.customer == null ? 'Customer added' : 'Customer updated')));
}
} catch (e) {
if (mounted) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Error'),
content: Text(e.toString()),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))
],
)
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.customer == null ? "New Customer" : "Edit Customer", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 24),
Center(
child: GestureDetector(
onTap: _showImagePickerModal,
child: Stack(
children: [
CircleAvatar(
radius: 50,
backgroundColor: Colors.grey[200],
backgroundImage: _photo != null
? FileImage(File(_photo!.path)) as ImageProvider
: (widget.customer?.photoUrl != null && _token != null
? NetworkImage(
'${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content',
headers: {'Authorization': 'Bearer $_token'},
)
: null),
child: _photo == null && widget.customer?.photoUrl == null
? const Icon(LucideIcons.user, size: 50, color: Colors.grey)
: null,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
),
),
],
),
),
),
const SizedBox(height: 24),
PremiumTextField(
controller: _nameCtrl,
labelText: 'Customer Name *',
prefixIcon: const Icon(LucideIcons.user),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
),
const SizedBox(height: 16),
PremiumTextField(
controller: _phoneCtrl,
labelText: 'Phone Number',
prefixIcon: const Icon(LucideIcons.phone),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
PremiumTextField(
controller: _fatherNameCtrl,
labelText: 'Father Name',
prefixIcon: const Icon(LucideIcons.users),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _ageCtrl,
labelText: 'Age',
prefixIcon: const Icon(LucideIcons.calendar),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedGender,
isExpanded: true,
hint: const Text('Gender'),
items: ['Male', 'Female', 'Other']
.map((g) => DropdownMenuItem(value: g, child: Text(g)))
.toList(),
onChanged: (val) => setState(() => _selectedGender = val),
),
),
),
),
],
),
const SizedBox(height: 16),
PremiumTextField(
controller: _emailCtrl,
labelText: 'Email Address',
prefixIcon: const Icon(LucideIcons.mail),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
PremiumTextField(
controller: _gstinCtrl,
labelText: 'GSTIN (Optional)',
prefixIcon: const Icon(LucideIcons.building),
textCapitalization: TextCapitalization.characters,
validator: (val) {
if (val == null || val.isEmpty) return null; // Optional
final RegExp gstRegExp = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$');
if (!gstRegExp.hasMatch(val.toUpperCase())) {
return 'Invalid GSTIN format';
}
return null;
},
),
const SizedBox(height: 16),
PremiumTextField(
controller: _idNumberCtrl,
labelText: 'ID No. (Aadhar, License, etc.)',
prefixIcon: const Icon(LucideIcons.creditCard),
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 16),
Consumer(
builder: (context, ref, _) {
final statesState = ref.watch(indianStatesProvider);
return statesState.when(
data: (states) {
return SmartSearchDropdown<int>(
hintText: 'Select State',
value: _selectedStateId,
items: states.map((s) => s.id).toList(),
itemAsString: (id) {
final s = states.firstWhere((st) => st.id == id);
return '${s.name} (${s.gstCode})';
},
onChanged: (val) => setState(() => _selectedStateId = val),
);
},
loading: () => const CircularProgressIndicator(),
error: (e, stack) => Text('Error loading states: $e'),
);
},
),
const SizedBox(height: 16),
PremiumTextField(
controller: _addressCtrl,
labelText: 'Billing Address',
prefixIcon: const Icon(LucideIcons.mapPin),
maxLines: 3,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _save,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.customer == null ? 'Save Customer' : 'Update Customer'),
),
),
const SizedBox(height: 24),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../domain/customer.dart';
import '../providers/invoices_provider.dart';
import '../../transactions/providers/providers.dart';
class CustomerLedgerScreen extends ConsumerStatefulWidget {
final Customer customer;
const CustomerLedgerScreen({super.key, required this.customer});
@override
ConsumerState<CustomerLedgerScreen> createState() => _CustomerLedgerScreenState();
}
class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
@override
Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider);
final formatCurrency = NumberFormat.currency(symbol: '');
return Scaffold(
appBar: AppBar(
title: Text('${widget.customer.name} Ledger'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: invoicesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (invoices) {
final customerInvoices = invoices.where((i) => i.customerId == widget.customer.id).toList();
if (customerInvoices.isEmpty) {
return const Center(child: Text('No invoices found for this customer.'));
}
// Sort chronological
customerInvoices.sort((a, b) => a.issueDate.compareTo(b.issueDate));
double runningBalance = 0.0;
List<DataRow> rows = [];
final wallets = ref.read(walletProvider).value ?? [];
int index = 1;
for (var inv in customerInvoices) {
// Add row for Invoice (Debit)
runningBalance += inv.totalAmount;
rows.add(DataRow(
cells: [
DataCell(Text('${index++}')),
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))),
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
const DataCell(Text('Invoice')),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(Text(formatCurrency.format(inv.totalAmount), style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.bold))),
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
// Add row for Payment (Credit)
if (inv.payments != null && inv.payments!.isNotEmpty) {
for (var p in inv.payments!) {
runningBalance -= p.amount;
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name;
rows.add(DataRow(
cells: [
DataCell(Text('${index++}')),
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : DateFormat('dd MMM yyyy').format(inv.issueDate))),
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
const DataCell(Text('Payment')),
DataCell(Text(p.paymentMethod)),
DataCell(Text(p.walletId != null ? walletName : '-')),
DataCell(Text(formatCurrency.format(p.amount), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
} else if (inv.amountPaid > 0) {
runningBalance -= inv.amountPaid;
rows.add(DataRow(
cells: [
DataCell(Text('${index++}')),
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))),
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))),
const DataCell(Text('Payment')),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(Text(formatCurrency.format(inv.amountPaid), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
}
return Column(
children: [
Container(
width: double.infinity,
color: Colors.blue.withOpacity(0.05),
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Text('Outstanding Balance', style: TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 8),
Text(
formatCurrency.format(runningBalance),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: runningBalance > 0 ? Colors.red.shade700 : Colors.green.shade700,
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Invoice #', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Type', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Mode', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: rows,
),
),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/customers_provider.dart';
import 'dart:async';
import '../../../core/widgets/shimmer_loading.dart';
import 'add_customer_sheet.dart';
import 'customer_ledger_screen.dart';
import '../providers/invoices_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
import '../../../core/network/dio_client.dart';
import 'package:intl/intl.dart';
class CustomersListScreen extends ConsumerStatefulWidget {
const CustomersListScreen({super.key});
@override
ConsumerState<CustomersListScreen> createState() => _CustomersListScreenState();
}
class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
final TextEditingController _searchCtrl = TextEditingController();
Timer? _debounce;
String? _token;
@override
void initState() {
super.initState();
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
if (mounted) setState(() => _token = val);
});
}
@override
void dispose() {
_searchCtrl.dispose();
_debounce?.cancel();
super.dispose();
}
void _onSearchChanged(String val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(customersProvider.notifier).refresh(val);
});
}
@override
Widget build(BuildContext context) {
final customersState = ref.watch(customersProvider);
final formatCurrency = NumberFormat.currency(symbol: '', decimalDigits: 0);
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('Customers', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
),
body: Column(
children: [
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
hintText: 'Search by name or phone...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
onChanged: _onSearchChanged,
),
),
Expanded(
child: customersState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (context, index) => const Padding(
padding: EdgeInsets.only(bottom: 12),
child: ShimmerCard(),
),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (customers) {
if (customers.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.users, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No customers found.', style: TextStyle(color: Colors.grey, fontSize: 16)),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(customersProvider.notifier).refresh(_searchCtrl.text);
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: customers.length,
itemBuilder: (context, index) {
final customer = customers[index];
return Consumer(
builder: (context, ref, _) {
final invoicesState = ref.watch(invoicesProvider);
double outstandingBalance = 0.0;
if (invoicesState.hasValue) {
for (var inv in invoicesState.value!) {
if (inv.customerId == customer.id && inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.status != 'DRAFT') {
outstandingBalance += (inv.totalAmount - inv.amountPaid);
}
}
}
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => CustomerLedgerScreen(customer: customer)));
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Hero(
tag: 'avatar_${customer.id}',
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.blue.shade50,
backgroundImage: (customer.photoUrl != null && _token != null)
? NetworkImage(
'${DioClient().dio.options.baseUrl}/customers/${customer.id}/photo/content',
headers: {'Authorization': 'Bearer $_token'},
)
: null,
child: (customer.photoUrl == null)
? Text(customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?', style: TextStyle(color: Colors.blue.shade700, fontWeight: FontWeight.bold, fontSize: 24))
: null,
),
),
const SizedBox(width: 16),
// Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(customer.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black87)),
const SizedBox(height: 6),
if (customer.phone != null && customer.phone!.isNotEmpty)
Row(
children: [
Icon(LucideIcons.phone, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 6),
Text(customer.phone!, style: TextStyle(color: Colors.grey.shade700, fontSize: 14)),
],
),
if (customer.gstin != null && customer.gstin!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(LucideIcons.building, size: 14, color: Colors.grey.shade600),
const SizedBox(width: 6),
Text(customer.gstin!, style: TextStyle(color: Colors.grey.shade700, fontSize: 13, fontWeight: FontWeight.w600)),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: outstandingBalance > 0 ? Colors.red.shade50 : Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
outstandingBalance > 0 ? 'Pending: ${formatCurrency.format(outstandingBalance)}' : 'Settled',
style: TextStyle(
color: outstandingBalance > 0 ? Colors.red.shade700 : Colors.green.shade700,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
Row(
children: [
IconButton(
icon: const Icon(LucideIcons.edit2, size: 20, color: Colors.blue),
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddCustomerSheet(customer: customer),
);
},
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(LucideIcons.trash2, size: 20, color: Colors.redAccent),
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
onPressed: () {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Customer'),
content: Text('Are you sure you want to delete ${customer.name}? This action cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')),
TextButton(
onPressed: () {
ref.read(customersProvider.notifier).deleteCustomer(customer.id!);
Navigator.pop(ctx);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
},
),
],
),
],
),
],
),
),
],
),
),
),
),
);
},
);
},
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const AddCustomerSheet(),
);
},
icon: const Icon(LucideIcons.plus),
label: const Text('Add Customer', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.blue,
),
);
}
}

View File

@@ -0,0 +1,708 @@
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 '../../business/providers/business_provider.dart';
import '../../../core/widgets/barcode_scanner_screen.dart';
import '../../../core/widgets/smart_search_dropdown.dart';
import '../../../core/widgets/premium_text_field.dart';
import '../providers/invoices_provider.dart';
import '../domain/invoice.dart';
import '../providers/customers_provider.dart';
import '../domain/customer.dart';
import '../../inventory/providers/products_provider.dart';
import '../../inventory/domain/product.dart';
import 'add_customer_sheet.dart';
import '../../transactions/providers/providers.dart';
class InvoiceBuilderScreen extends ConsumerStatefulWidget {
const InvoiceBuilderScreen({super.key});
@override
ConsumerState<InvoiceBuilderScreen> createState() => _InvoiceBuilderScreenState();
}
class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
final _formKey = GlobalKey<FormState>();
Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
bool _isEmi = false;
String _emiCycle = 'MONTHLY';
final TextEditingController _emiAmountCtrl = TextEditingController();
final TextEditingController _invoiceNumberCtrl = TextEditingController(text: 'INV-${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}');
final TextEditingController _invoiceDiscountCtrl = TextEditingController();
bool _invoiceDiscountIsPerc = false;
DateTime _invoiceDate = DateTime.now();
final TextEditingController _amountPaidCtrl = TextEditingController();
bool _isAmountPaidEdited = false;
DateTime? _nextPaymentDate;
String _paymentMethod = 'Cash';
int? _selectedWalletId;
double get _subtotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
double get _taxTotal => _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice * (item.taxRate / 100)));
double get _itemDiscountTotal => _items.fold(0, (sum, item) => sum + item.discount);
double get _invoiceDiscountAmount {
final raw = double.tryParse(_invoiceDiscountCtrl.text) ?? 0;
if (_invoiceDiscountIsPerc) {
final taxableAmount = _subtotal + _makingChargeTotal + _otherChargesTotal;
return taxableAmount * (raw / 100);
}
return raw;
}
double get _discountTotal => _itemDiscountTotal + _invoiceDiscountAmount;
double get _makingChargeTotal => _items.fold(0, (sum, item) => sum + item.makingCharge);
double get _otherChargesTotal => _items.fold(0, (sum, item) => sum + item.otherCharges);
double get _grandTotal => _subtotal + _taxTotal + _makingChargeTotal + _otherChargesTotal - _discountTotal;
double get _amountPaid {
if (!_isAmountPaidEdited) return _grandTotal;
final raw = double.tryParse(_amountPaidCtrl.text) ?? 0;
return raw > _grandTotal ? _grandTotal : raw; // Cap at grand total
}
double get _balanceDue => _grandTotal - _amountPaid;
Future<void> _saveInvoice() async {
if (!_formKey.currentState!.validate() || _items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add items and fill all required fields.')));
return;
}
final invoice = Invoice(
customerId: _selectedCustomer?.id,
invoiceNumber: _invoiceNumberCtrl.text,
issueDate: _invoiceDate,
dueDate: DateTime.now().add(const Duration(days: 30)),
subtotal: _subtotal,
taxTotal: _taxTotal,
discountTotal: _discountTotal,
totalAmount: _grandTotal,
amountPaid: _amountPaid,
paymentMethod: _amountPaid > 0 ? _paymentMethod : null,
paymentWalletId: _amountPaid > 0 ? (_selectedWalletId ?? (ref.read(walletProvider).value?.firstOrNull?.id)) : null,
nextPaymentDate: _balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
isEmi: _isEmi,
emiAmount: _isEmi && _emiAmountCtrl.text.isNotEmpty ? double.parse(_emiAmountCtrl.text) : null,
emiCycle: _isEmi ? _emiCycle : null,
emiStartDate: _isEmi ? DateTime.now().add(const Duration(days: 30)) : null,
items: _items,
);
try {
await ref.read(invoicesProvider.notifier).createInvoice(invoice);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invoice Created!')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
void _showAddItemDialog() {
Product? selectedProduct;
final TextEditingController descCtrl = TextEditingController();
final TextEditingController qtyCtrl = TextEditingController(text: '1');
final TextEditingController priceCtrl = TextEditingController();
final TextEditingController taxCtrl = TextEditingController(text: '0');
final TextEditingController discountCtrl = TextEditingController(text: '0');
final TextEditingController makingCtrl = TextEditingController(text: '0');
final TextEditingController otherCtrl = TextEditingController(text: '0');
String uomStr = '';
showDialog(
context: context,
builder: (context) {
bool isDiscPerc = false;
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Add Line Item'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Consumer(
builder: (context, dialogRef, _) {
final productsState = dialogRef.watch(productsProvider);
return productsState.when(
data: (products) => Row(
children: [
Expanded(
child: SmartSearchDropdown<Product>(
hintText: 'Select Product (Optional)',
value: selectedProduct,
items: products,
itemAsString: (p) => '${p.name} ${p.sku != null && p.sku!.isNotEmpty ? "(${p.sku})" : ""} (₹${p.sellingPrice})',
onChanged: (p) {
setDialogState(() {
selectedProduct = p;
if (p != null) {
descCtrl.text = p.name;
priceCtrl.text = p.sellingPrice?.toString() ?? '0';
taxCtrl.text = p.gstRate?.toString() ?? '0';
makingCtrl.text = p.makingCharges?.toString() ?? '0';
uomStr = '';
}
});
}
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () async {
final String? code = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
);
if (code != null && code.isNotEmpty) {
final source = ref.read(businessFeatureProvider).value?.barcodeSource ?? 'SKU';
Product? matched;
if (source == 'BARCODE') {
matched = products.cast<Product?>().firstWhere((p) => p?.barcode == code, orElse: () => null);
} else {
matched = products.cast<Product?>().firstWhere((p) => p?.sku == code, orElse: () => null);
}
if (matched != null) {
setDialogState(() {
selectedProduct = matched;
descCtrl.text = matched!.name;
priceCtrl.text = matched!.sellingPrice?.toString() ?? '0';
taxCtrl.text = matched!.gstRate?.toString() ?? '0';
makingCtrl.text = matched!.makingCharges?.toString() ?? '0';
});
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('No product found for $source: $code')));
}
}
}
},
icon: const Icon(LucideIcons.scanLine),
color: Theme.of(context).colorScheme.primary,
tooltip: 'Scan Barcode',
)
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Text('Error loading products: $e'),
);
}
),
const SizedBox(height: 12),
PremiumTextField(controller: descCtrl, labelText: 'Description'),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: PremiumTextField(controller: qtyCtrl, labelText: uomStr.isNotEmpty ? 'Qty ($uomStr)' : 'Quantity', keyboardType: TextInputType.number)),
const SizedBox(width: 8),
Expanded(child: PremiumTextField(controller: priceCtrl, labelText: 'Unit Price', keyboardType: TextInputType.number)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: PremiumTextField(controller: taxCtrl, labelText: 'Tax %', keyboardType: TextInputType.number)),
const SizedBox(width: 8),
Expanded(
child: PremiumTextField(
controller: discountCtrl,
labelText: isDiscPerc ? 'Discount %' : 'Discount (₹)',
keyboardType: TextInputType.number,
suffixIcon: IconButton(
icon: Icon(isDiscPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
onPressed: () => setDialogState(() => isDiscPerc = !isDiscPerc),
),
)
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: PremiumTextField(controller: makingCtrl, labelText: 'Making Chg', keyboardType: TextInputType.number)),
const SizedBox(width: 8),
Expanded(child: PremiumTextField(controller: otherCtrl, labelText: 'Other Chg', keyboardType: TextInputType.number)),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () {
final qty = double.tryParse(qtyCtrl.text) ?? 1;
final price = double.tryParse(priceCtrl.text) ?? 0;
final tax = double.tryParse(taxCtrl.text) ?? 0;
final discRaw = double.tryParse(discountCtrl.text) ?? 0;
final disc = isDiscPerc ? ((qty * price) * (discRaw / 100)) : discRaw;
final making = double.tryParse(makingCtrl.text) ?? 0;
final other = double.tryParse(otherCtrl.text) ?? 0;
final total = (qty * price) + (qty * price * (tax / 100)) + making + other - disc;
setState(() {
_items.add(InvoiceItem(
productId: selectedProduct?.id,
sku: selectedProduct?.sku,
description: descCtrl.text,
quantity: qty,
unitPrice: price,
taxRate: tax,
discount: disc,
makingCharge: making,
otherCharges: other,
total: total,
));
});
Navigator.pop(context);
},
child: const Text('Add Item'),
),
],
);
},
);
},
);
}
void _scanAndAddBarcode() async {
final barcode = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const BarcodeScannerScreen()),
);
if (barcode != null && barcode.isNotEmpty) {
final products = ref.read(productsProvider).value ?? [];
final businessFeature = ref.read(businessFeatureProvider).value;
final bool useBarcodeField = businessFeature?.barcodeSource == 'BARCODE';
final p = products.where((p) {
if (useBarcodeField) {
return p.barcode?.toLowerCase() == barcode.toLowerCase();
} else {
return p.sku?.toLowerCase() == barcode.toLowerCase();
}
}).firstOrNull;
if (p != null) {
setState(() {
final existingIndex = _items.indexWhere((item) => item.productId == p.id);
if (existingIndex >= 0) {
// Increment qty
final item = _items[existingIndex];
final newQty = item.quantity + 1;
final newTotal = (newQty * item.unitPrice) + (newQty * item.unitPrice * (item.taxRate / 100)) + item.makingCharge + item.otherCharges - item.discount;
_items[existingIndex] = item.copyWith(quantity: newQty, total: newTotal);
} else {
// Add new
final price = p.sellingPrice ?? 0;
final tax = p.gstRate ?? 0;
final making = p.makingCharges ?? 0;
final total = price + (price * (tax / 100)) + making;
_items.add(InvoiceItem(
productId: p.id,
sku: p.sku,
description: p.name,
quantity: 1,
unitPrice: price,
taxRate: tax,
makingCharge: making,
otherCharges: 0,
discount: 0,
total: total,
));
}
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${p.name} to invoice')));
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Product not found for barcode: $barcode')));
}
}
}
}
@override
Widget build(BuildContext context) {
final customersState = ref.watch(customersProvider);
final formatCurrency = NumberFormat.currency(symbol: '');
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text('Create Invoice'),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
actions: [
TextButton(
onPressed: _saveInvoice,
child: const Text('Save Invoice', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
)
],
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Customer Selection
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Customer', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => const AddCustomerSheet(),
);
},
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Customer'),
),
],
),
const SizedBox(height: 8),
customersState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading customers: $err'),
data: (customers) => SmartSearchDropdown<Customer>(
hintText: 'Search a Customer...',
value: _selectedCustomer,
items: customers,
itemAsString: (c) => c.name,
onChanged: (val) => setState(() => _selectedCustomer = val),
),
),
],
),
),
const SizedBox(height: 16),
// Invoice Details
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Invoice Details', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 12),
PremiumTextField(
controller: _invoiceNumberCtrl,
labelText: 'Invoice Number',
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Invoice Date'),
subtitle: Text(DateFormat('yyyy-MM-dd').format(_invoiceDate)),
trailing: const Icon(LucideIcons.calendar),
onTap: () async {
final dt = await showDatePicker(
context: context,
initialDate: _invoiceDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (dt != null) {
setState(() => _invoiceDate = dt);
}
},
),
const SizedBox(height: 12),
],
),
),
const SizedBox(height: 16),
// Items
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Line Items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Row(
children: [
IconButton(
icon: const Icon(LucideIcons.scanLine, color: Colors.blue),
onPressed: _scanAndAddBarcode,
tooltip: 'Scan to add',
),
TextButton.icon(
onPressed: _showAddItemDialog,
icon: const Icon(LucideIcons.plus),
label: const Text('Add'),
),
],
)
],
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: Text('No items added', style: TextStyle(color: Colors.grey))),
),
for (var i = 0; i < _items.length; i++)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[50],
border: Border.all(color: Colors.grey[200]!),
borderRadius: BorderRadius.circular(12)
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_items[i].description ?? 'Item ${i+1}', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
if (_items[i].sku != null && _items[i].sku!.isNotEmpty) Text('SKU: ${_items[i].sku}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
Text('Qty: ${_items[i].quantity}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
Text('Rate: ${formatCurrency.format(_items[i].unitPrice)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (_items[i].taxRate > 0) Text('Tax: ${_items[i].taxRate}%', style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (_items[i].makingCharge > 0) Text('Making: ${formatCurrency.format(_items[i].makingCharge)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (_items[i].otherCharges > 0) Text('Other: ${formatCurrency.format(_items[i].otherCharges)}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
if (_items[i].discount > 0) Text('Disc: -${formatCurrency.format(_items[i].discount)}', style: const TextStyle(fontSize: 12, color: Colors.red)),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(formatCurrency.format(_items[i].total), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 18),
onPressed: () => setState(() => _items.removeAt(i)),
),
],
)
],
),
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Invoice Discount', style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(
width: 150,
child: TextField(
controller: _invoiceDiscountCtrl,
textAlign: TextAlign.right,
decoration: InputDecoration(
hintText: '0.00',
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
prefixIcon: IconButton(
icon: Icon(_invoiceDiscountIsPerc ? LucideIcons.percent : LucideIcons.indianRupee, size: 16),
onPressed: () => setState(() => _invoiceDiscountIsPerc = !_invoiceDiscountIsPerc),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) {
setState(() {
if (!_isAmountPaidEdited) {
_amountPaidCtrl.text = _grandTotal.toStringAsFixed(2);
}
});
},
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Amount', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
Text(formatCurrency.format(_grandTotal), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.blue)),
],
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Amount Paid Now', style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(
width: 150,
child: TextField(
controller: _amountPaidCtrl,
textAlign: TextAlign.right,
decoration: InputDecoration(
hintText: _grandTotal.toStringAsFixed(2),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() {
_isAmountPaidEdited = true;
}),
),
),
],
),
if (_amountPaid > 0) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(
width: 150,
child: Consumer(
builder: (context, consumerRef, _) {
final walletsState = consumerRef.watch(walletProvider);
final wallets = walletsState.value ?? [];
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedWalletId ?? wallets.firstOrNull?.id,
isDense: true,
hint: const Text('Wallet'),
items: wallets
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name)))
.toList(),
onChanged: (val) => setState(() => _selectedWalletId = val),
),
);
},
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600)),
SizedBox(
width: 150,
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _paymentMethod,
isDense: true,
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
.toList(),
onChanged: (val) => setState(() => _paymentMethod = val!),
),
),
),
],
),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Balance Due', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
Text(formatCurrency.format(_balanceDue), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
],
),
if (_balanceDue > 0) ...[
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Enable EMI / Installments'),
value: _isEmi,
onChanged: (val) => setState(() => _isEmi = val),
contentPadding: EdgeInsets.zero,
),
if (_isEmi) ...[
Row(
children: [
Expanded(
child: PremiumTextField(
controller: _emiAmountCtrl,
labelText: 'EMI Amount',
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: SmartSearchDropdown<String>(
hintText: 'Cycle',
value: _emiCycle,
items: const ['MONTHLY', 'WEEKLY'],
itemAsString: (val) => val == 'MONTHLY' ? 'Monthly' : 'Weekly',
onChanged: (val) => setState(() => _emiCycle = val!),
),
),
],
)
] else ...[
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Next Payment Date', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(_nextPaymentDate == null
? 'Not Selected'
: DateFormat('yyyy-MM-dd').format(_nextPaymentDate!)),
trailing: const Icon(LucideIcons.calendar),
onTap: () async {
final dt = await showDatePicker(
context: context,
initialDate: _nextPaymentDate ?? DateTime.now().add(const Duration(days: 30)),
firstDate: DateTime.now(),
lastDate: DateTime(2100),
);
if (dt != null) {
setState(() => _nextPaymentDate = dt);
}
},
),
],
],
],
),
),
const SizedBox(height: 32),
],
),
),
),
),
);
}
}

View File

@@ -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')));
}
}
}

View File

@@ -0,0 +1,382 @@
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 '../../../core/widgets/shimmer_loading.dart';
import '../providers/invoices_provider.dart';
import '../domain/invoice.dart';
import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart';
import '../../transactions/providers/providers.dart';
import 'widgets/receive_payment_sheet.dart';
import '../providers/customers_provider.dart';
class InvoicesListScreen extends ConsumerStatefulWidget {
const InvoicesListScreen({super.key});
@override
ConsumerState<InvoicesListScreen> createState() => _InvoicesListScreenState();
}
class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _searchQuery = '';
final TextEditingController _searchController = TextEditingController();
String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
if (invoice.status == 'PAID') return 'Fully Paid';
if (invoice.status == 'PARTIAL') return 'Partially Paid';
return invoice.status;
}
Color _getStatusColor(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple;
switch (invoice.status) {
case 'DRAFT': return Colors.grey;
case 'FINALIZED': return Colors.orange;
case 'PAID': return Colors.green;
case 'PARTIAL': return Colors.blue;
case 'OVERDUE': return Colors.red;
case 'CANCELLED': return Colors.black;
default: return Colors.grey;
}
}
void _showPaymentHistory(BuildContext context, WidgetRef ref, Invoice invoice) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Payment History: ${invoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
FutureBuilder<List<InvoicePayment>>(
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(invoice.id!),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final payments = snapshot.data;
if (payments == null || payments.isEmpty) {
return const Padding(
padding: EdgeInsets.all(24.0),
child: Text('No payments recorded yet.'),
);
}
final wallets = ref.read(walletProvider).value ?? [];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Method', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: payments.map((p) {
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name;
return DataRow(
cells: [
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')),
DataCell(Text(p.walletId != null ? walletName : '-')),
DataCell(Text(p.paymentMethod ?? '-')),
DataCell(Text('${p.amount.toStringAsFixed(2)}', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))),
],
);
}).toList(),
),
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Close'),
),
),
],
),
);
},
);
}
void _showReceivePaymentSheet(BuildContext context, Invoice invoice) async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ReceivePaymentSheet(invoice: invoice),
);
if (result == true) {
ref.read(invoicesProvider.notifier).refresh();
}
}
@override
Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider);
final customers = customersState.value ?? [];
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text('Invoices'),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
body: Column(
children: [
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by Invoice # or Customer',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
),
Expanded(
child: invoicesState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (context, index) => const Padding(
padding: EdgeInsets.only(bottom: 12),
child: ShimmerCard(),
),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (allInvoices) {
final invoices = allInvoices.where((invoice) {
final matchesInvoice = invoice.invoiceNumber.toLowerCase().contains(_searchQuery);
final customer = customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first);
final customerName = invoice.customerId != null ? customer.name.toLowerCase() : '';
final matchesCustomer = customerName.contains(_searchQuery);
return matchesInvoice || matchesCustomer;
}).toList();
if (invoices.isEmpty) {
return RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 100),
Center(child: Text('No invoices found.', style: TextStyle(color: Colors.grey))),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: invoices.length,
itemBuilder: (context, index) {
final invoice = invoices[index];
double remaining = invoice.totalAmount - invoice.amountPaid;
final customer = invoice.customerId != null
? customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first)
: null;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)),
);
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(invoice.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (customer != null)
Text(customer.name, style: TextStyle(color: Colors.grey[700], fontSize: 13, fontWeight: FontWeight.w600)),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(invoice).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_getStatusLabel(invoice),
style: TextStyle(color: _getStatusColor(invoice), fontSize: 12, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Issued: ${formatDate.format(invoice.issueDate)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
if (invoice.dueDate != null)
Text('Due: ${formatDate.format(invoice.dueDate!)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
if (invoice.nextPaymentDate != null && remaining > 0)
Text('Next Pmt: ${formatDate.format(invoice.nextPaymentDate!)}', style: TextStyle(color: Colors.orange.shade700, fontSize: 13, fontWeight: FontWeight.bold)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatCurrency.format(invoice.totalAmount),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
if (invoice.amountPaid > 0)
Text(
'Paid: ${formatCurrency.format(invoice.amountPaid)}',
style: TextStyle(color: Colors.green.shade700, fontSize: 12),
),
if (remaining > 0 && invoice.status != 'DRAFT')
Text(
'Bal: ${formatCurrency.format(remaining)}',
style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
],
),
if (invoice.isEmi && invoice.emiAmount != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.purple.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.purple.withOpacity(0.2)),
),
child: Row(
children: [
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple),
const SizedBox(width: 8),
Text('EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}', style: const TextStyle(color: Colors.purple, fontSize: 12)),
],
),
)
],
const Divider(height: 24),
Row(
children: [
if (invoice.amountPaid > 0)
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showPaymentHistory(context, ref, invoice),
icon: const Icon(LucideIcons.history, size: 14),
label: const Text('History', style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.blue.shade700,
side: BorderSide(color: Colors.blue.shade200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
if (invoice.amountPaid > 0 && remaining > 0) const SizedBox(width: 8),
if (remaining > 0)
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showReceivePaymentSheet(context, invoice),
icon: const Icon(LucideIcons.indianRupee, size: 14),
label: const Text('Receive', style: TextStyle(fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade50,
foregroundColor: Colors.blue.shade700,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
),
),
),
);
},
),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()));
},
icon: const Icon(LucideIcons.plus),
label: const Text('Create Invoice'),
backgroundColor: Colors.blue,
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/widgets/premium_text_field.dart';
import '../../../transactions/providers/providers.dart';
import '../../providers/invoices_provider.dart';
import '../../domain/invoice.dart';
class ReceivePaymentSheet extends ConsumerStatefulWidget {
final Invoice invoice;
const ReceivePaymentSheet({super.key, required this.invoice});
@override
ConsumerState<ReceivePaymentSheet> createState() => _ReceivePaymentSheetState();
}
class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
final TextEditingController _amountCtrl = TextEditingController();
String _paymentMethod = 'Cash';
int? _selectedWalletId;
bool _isLoading = false;
@override
void initState() {
super.initState();
final balance = widget.invoice.totalAmount - widget.invoice.amountPaid;
_amountCtrl.text = balance.toStringAsFixed(2);
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
final wallets = walletsState.value ?? [];
if (wallets.isNotEmpty && _selectedWalletId == null) {
_selectedWalletId = wallets.first.id;
}
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 24,
left: 24,
right: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Receive Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
PremiumTextField(
controller: _amountCtrl,
labelText: 'Amount Paid (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedWalletId,
isExpanded: true,
hint: const Text('Select Wallet'),
items: wallets
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name)))
.toList(),
onChanged: (val) => setState(() => _selectedWalletId = val),
),
),
),
const SizedBox(height: 16),
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(16),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _paymentMethod,
isExpanded: true,
items: ['Cash', 'UPI', 'Bank Transfer', 'Card']
.map((m) => DropdownMenuItem(value: m, child: Text(m)))
.toList(),
onChanged: (val) => setState(() => _paymentMethod = val!),
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
onPressed: _isLoading ? null : () async {
final amount = double.tryParse(_amountCtrl.text) ?? 0;
if (amount <= 0) return;
setState(() => _isLoading = true);
try {
await ref.read(invoicesProvider.notifier).addPayment(
widget.invoice.id!,
InvoicePayment(
amount: amount,
paymentMethod: _paymentMethod,
walletId: _selectedWalletId,
),
);
if (mounted) {
Navigator.pop(context, true); // true indicates success
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment Received Successfully!')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
},
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Confirm Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
),
const SizedBox(height: 24),
],
),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import '../../../core/network/dio_client.dart';
import '../domain/customer.dart';
class CustomersNotifier extends AsyncNotifier<List<Customer>> {
@override
FutureOr<List<Customer>> build() async {
return _fetchCustomers();
}
Future<List<Customer>> _fetchCustomers([String? search]) async {
try {
final response = await DioClient().dio.get(
'/customers',
queryParameters: search != null && search.isNotEmpty ? {'search': search} : null,
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => Customer.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching customers: $e');
return [];
}
}
Future<void> refresh([String? search]) async {
state = const AsyncValue.loading();
try {
final customers = await _fetchCustomers(search);
state = AsyncValue.data(customers);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> addCustomer(Customer customer, {XFile? photo}) async {
try {
final response = await DioClient().dio.post(
'/customers',
data: customer.toJson(),
);
if (photo != null && response.data != null) {
final customerId = response.data['id'];
await _uploadPhoto(customerId, photo);
}
await refresh();
} catch (e) {
if (e is DioException) {
throw Exception('Failed to add customer: ${e.response?.statusCode} - ${e.response?.data}');
}
throw Exception('Failed to add customer: $e');
}
}
Future<void> updateCustomer(int id, Customer customer, {XFile? photo}) async {
try {
await DioClient().dio.put(
'/customers/$id',
data: customer.toJson(),
);
if (photo != null) {
await _uploadPhoto(id, photo);
}
await refresh();
} catch (e) {
if (e is DioException) {
throw Exception('Failed to update customer: ${e.response?.statusCode} - ${e.response?.data}');
}
throw Exception('Failed to update customer: $e');
}
}
Future<void> _uploadPhoto(int customerId, XFile photo) async {
final bytes = await photo.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 413,
minHeight: 531,
quality: 85,
);
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name),
});
await DioClient().dio.post(
'/customers/$customerId/photo',
data: formData,
);
}
Future<void> deleteCustomer(int id) async {
try {
await DioClient().dio.delete('/customers/$id');
await refresh();
} catch (e) {
throw Exception('Failed to delete customer: $e');
}
}
}
final customersProvider = AsyncNotifierProvider<CustomersNotifier, List<Customer>>(() {
return CustomersNotifier();
});

View File

@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../../core/network/dio_client.dart';
import '../domain/invoice.dart';
class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
@override
FutureOr<List<Invoice>> build() async {
return _fetchInvoices();
}
Future<List<Invoice>> _fetchInvoices() async {
try {
final response = await DioClient().dio.get('/invoices');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => Invoice.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching invoices: $e');
return [];
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
try {
final invoices = await _fetchInvoices();
state = AsyncValue.data(invoices);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> createInvoice(Invoice invoice) async {
try {
await DioClient().dio.post(
'/invoices',
data: invoice.toJson(),
);
await refresh();
} catch (e) {
if (e is DioException) {
throw Exception('Failed to create invoice: ${e.response?.statusCode} - ${e.response?.data}');
}
throw Exception('Failed to create invoice: $e');
}
}
Future<void> finalizeInvoice(int invoiceId) async {
try {
await DioClient().dio.put('/invoices/$invoiceId/finalize');
await refresh();
} catch (e) {
throw Exception('Failed to finalize invoice: $e');
}
}
Future<void> addPayment(int invoiceId, InvoicePayment payment) async {
try {
await DioClient().dio.post(
'/invoices/$invoiceId/payments',
data: payment.toJson(),
);
await refresh();
} catch (e) {
if (e is DioException) {
throw Exception('Failed to add payment: ${e.response?.statusCode} - ${e.response?.data}');
}
throw Exception('Failed to add payment: $e');
}
}
Future<List<InvoicePayment>> fetchPaymentsForInvoice(int invoiceId) async {
try {
final response = await DioClient().dio.get('/invoices/$invoiceId/payments');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => InvoicePayment.fromJson(e)).toList();
}
return [];
} catch (e) {
print('Error fetching payments for invoice $invoiceId: $e');
return [];
}
}
}
final invoicesProvider = AsyncNotifierProvider<InvoicesNotifier, List<Invoice>>(() {
return InvoicesNotifier();
});