Revamp Done - Support for Individual and Jwellery module added

This commit is contained in:
2026-08-29 12:01:00 +05:30
parent eacca6aaea
commit cdc58c9bce
37 changed files with 4334 additions and 2935 deletions

View File

@@ -2,14 +2,12 @@ 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 '../providers/customers_provider.dart';
import '../domain/customer.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});
@@ -24,277 +22,115 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
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';
if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID';
if (invoice.amountPaid > 0) return 'PARTIAL';
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;
final label = _getStatusLabel(invoice);
switch (label) {
case 'PAID':
return Colors.green;
case 'PARTIAL':
return Colors.blue;
case 'FINALIZED':
return Colors.orange;
case 'OVERDUE':
return Colors.red;
case 'CANCELLED':
return Colors.black;
case 'DRAFT':
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
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider);
final customers = customersState.value ?? [];
final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('MMM dd, yyyy');
return Scaffold(
backgroundColor: Colors.grey[100],
backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar(
title: const Text(
'Invoices',
'Sales Invoices',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: isDark ? Colors.white : Colors.black,
centerTitle: true,
),
body: Column(
children: [
// Unified Search Header
Container(
color: Colors.white,
color: isDark ? const Color(0xFF1E293B) : Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by Invoice # or Customer',
hintText: 'Search Invoice # or Customer...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: isDark ? Colors.white12 : Colors.grey.shade300),
),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(LucideIcons.x, size: 20),
icon: const Icon(LucideIcons.xCircle, size: 20),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
setState(() => _searchQuery = '');
},
)
: null,
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
onChanged: (val) => setState(() => _searchQuery = val.trim()),
),
),
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;
data: (invoices) {
final customers = customersState.value ?? [];
final filtered = invoices.where((inv) {
final matchesInv = inv.invoiceNumber.toLowerCase().contains(_searchQuery.toLowerCase());
final customer = customers.where((c) => c.id == inv.customerId).firstOrNull;
final matchesCust = customer != null && customer.name.toLowerCase().contains(_searchQuery.toLowerCase());
return matchesInv || matchesCust;
}).toList();
if (invoices.isEmpty) {
if (filtered.isEmpty) {
return RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
onRefresh: () => ref.refresh(invoicesProvider.future),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 100),
SizedBox(height: 120),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.receipt,
size: 64,
color: Colors.grey,
),
Icon(LucideIcons.fileText, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No invoices found.',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
'No sales invoices found.',
style: TextStyle(color: Colors.grey, fontSize: 16),
),
],
),
@@ -305,32 +141,25 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
},
onRefresh: () => ref.refresh(invoicesProvider.future),
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: invoices.length,
itemCount: filtered.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;
final invoice = filtered[index];
final customer = customers.where((c) => c.id == invoice.customerId).firstOrNull;
final statusColor = _getStatusColor(invoice);
final statusLabel = _getStatusLabel(invoice);
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.08),
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -341,13 +170,21 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
InvoiceDetailsScreen(invoice: invoice),
),
);
if (invoice.status == 'DRAFT') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceBuilderScreen(existingInvoice: invoice),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceDetailsScreen(invoice: invoice),
),
);
}
},
borderRadius: BorderRadius.circular(20),
child: Padding(
@@ -355,49 +192,28 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Invoice Number + Status Pill
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
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,
),
),
],
Text(
invoice.invoiceNumber,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: _getStatusColor(
invoice,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor.withValues(alpha: 0.3), width: 0.8),
),
child: Text(
_getStatusLabel(invoice),
statusLabel,
style: TextStyle(
color: _getStatusColor(invoice),
color: statusColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
@@ -406,174 +222,50 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
],
),
const SizedBox(height: 12),
// Row 2: Customer Name
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,
),
),
],
const Icon(LucideIcons.user, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Text(
customer?.name ?? 'Walk-in Customer',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (invoice.items.isNotEmpty)
Text(
'${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
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),
const SizedBox(height: 10),
// Row 3: Date & Total Amount
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,
),
),
),
const Icon(LucideIcons.calendar, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Text(
formatDate.format(invoice.issueDate),
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 13,
),
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,
),
),
),
),
const Spacer(),
Text(
formatCurrency.format(invoice.totalAmount),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.green,
),
),
],
),
],
@@ -586,6 +278,8 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(child: Text('Error: $e')),
),
),
],
@@ -598,8 +292,9 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
);
},
icon: const Icon(LucideIcons.plus),
label: const Text('Create Invoice'),
label: const Text('Create Invoice', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
);
}

View File

@@ -0,0 +1,171 @@
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 '../../../../core/widgets/smart_search_dropdown.dart';
import '../../providers/customers_provider.dart';
import '../../domain/customer.dart';
import '../../../business/providers/indian_states_provider.dart';
class QuickAddCustomerSheet extends ConsumerStatefulWidget {
const QuickAddCustomerSheet({super.key});
@override
ConsumerState<QuickAddCustomerSheet> createState() => _QuickAddCustomerSheetState();
}
class _QuickAddCustomerSheetState extends ConsumerState<QuickAddCustomerSheet> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _addressCtrl = TextEditingController();
final _gstinCtrl = TextEditingController();
int? _selectedStateId;
bool _isLoading = false;
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_addressCtrl.dispose();
_gstinCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final customer = Customer(
name: _nameCtrl.text.trim(),
phone: _phoneCtrl.text.trim().isEmpty ? null : _phoneCtrl.text.trim(),
address: _addressCtrl.text.trim().isEmpty ? null : _addressCtrl.text.trim(),
gstin: _gstinCtrl.text.trim().isEmpty ? null : _gstinCtrl.text.trim(),
stateId: _selectedStateId,
);
final created = await ref.read(customersProvider.notifier).addCustomer(customer);
if (mounted) Navigator.pop(context, created ?? true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: () => Navigator.pop(context),
),
const Expanded(
child: Text(
'Quick Add Customer',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
),
const Divider(),
Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PremiumTextField(
controller: _nameCtrl,
labelText: 'Customer Name *',
prefixIcon: const Icon(LucideIcons.user),
textCapitalization: TextCapitalization.words,
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),
Consumer(
builder: (context, ref, _) {
final statesState = ref.watch(indianStatesProvider);
return statesState.when(
data: (states) => SmartSearchDropdown<int>(
labelText: 'State',
hintText: 'Select State',
value: _selectedStateId,
items: states.map((s) => s.id).toList(),
itemAsString: (id) => states.firstWhere((s) => s.id == id).name,
onChanged: (val) {
setState(() {
_selectedStateId = val;
});
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error loading states: $e'),
);
},
),
const SizedBox(height: 16),
PremiumTextField(
controller: _gstinCtrl,
labelText: 'GSTIN (Optional)',
prefixIcon: const Icon(LucideIcons.fileText),
textCapitalization: TextCapitalization.characters,
),
const SizedBox(height: 16),
PremiumTextField(
controller: _addressCtrl,
labelText: 'Address',
prefixIcon: const Icon(LucideIcons.home),
maxLines: 2,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _save,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('Save Customer', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
),
),
],
),
);
}
}