Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done
This commit is contained in:
72
kifi-app/lib/features/sales/domain/customer.dart
Normal file
72
kifi-app/lib/features/sales/domain/customer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
237
kifi-app/lib/features/sales/domain/invoice.dart
Normal file
237
kifi-app/lib/features/sales/domain/invoice.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
353
kifi-app/lib/features/sales/presentation/add_customer_sheet.dart
Normal file
353
kifi-app/lib/features/sales/presentation/add_customer_sheet.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
kifi-app/lib/features/sales/providers/customers_provider.dart
Normal file
115
kifi-app/lib/features/sales/providers/customers_provider.dart
Normal 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();
|
||||
});
|
||||
93
kifi-app/lib/features/sales/providers/invoices_provider.dart
Normal file
93
kifi-app/lib/features/sales/providers/invoices_provider.dart
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user