Web approach uniformity done

This commit is contained in:
2026-08-30 19:54:54 +05:30
parent 372c2bc14d
commit eb39d5ff90
12 changed files with 2724 additions and 1201 deletions

View File

@@ -4,7 +4,6 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart';
import '../../../inventory/presentation/product_list_screen.dart';
import '../../../inventory/presentation/daily_rates_screen.dart';
import '../../../inventory/presentation/quick_adjust_stock_screen.dart';
import '../../../inventory/presentation/category_management_screen.dart';
import '../../../sales/presentation/customers_list_screen.dart';
import '../../../sales/presentation/invoices_list_screen.dart';
@@ -60,12 +59,12 @@ class BusinessHubScreen extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
child: Row(
@@ -97,124 +96,140 @@ class BusinessHubScreen extends ConsumerWidget {
),
),
const SizedBox(height: 24),
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.2,
children: [
if (showInventory) ...[
_buildActionCard(
context,
'Categories',
'Manage catalog structure',
LucideIcons.listTree,
Colors.teal,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CategoryManagementScreen(),
LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth >= 768;
final crossAxisCount = isDesktop ? 3 : 2;
final childAspectRatio = isDesktop ? 2.2 : 1.25;
return GridView.count(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: childAspectRatio,
children: [
if (showInventory) ...[
_buildActionCard(
context,
'Categories',
'Manage catalog structure',
LucideIcons.listTree,
Colors.teal,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CategoryManagementScreen(),
),
),
isDesktop: isDesktop,
),
),
),
_buildActionCard(
context,
'Products Catalog',
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProductListScreen(),
_buildActionCard(
context,
'Products Catalog',
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProductListScreen(),
),
),
isDesktop: isDesktop,
),
),
),
],
if (showSales)
_buildActionCard(
context,
'Customers',
'Manage clients',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CustomersListScreen(),
],
if (showSales)
_buildActionCard(
context,
'Customers',
'Manage clients',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CustomersListScreen(),
),
),
isDesktop: isDesktop,
),
),
),
if (showPurchase)
_buildActionCard(
context,
'Vendors',
'Manage suppliers',
LucideIcons.truck,
Colors.orange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const VendorsListScreen(),
if (showPurchase)
_buildActionCard(
context,
'Vendors',
'Manage suppliers',
LucideIcons.truck,
Colors.orange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const VendorsListScreen(),
),
),
isDesktop: isDesktop,
),
),
),
if (showPurchase)
_buildActionCard(
context,
'Purchases',
'Manage inward stock',
LucideIcons.clipboardList,
Colors.deepOrange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PurchaseOrdersListScreen(),
if (showPurchase)
_buildActionCard(
context,
'Purchases',
'Manage inward stock',
LucideIcons.clipboardList,
Colors.deepOrange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PurchaseOrdersListScreen(),
),
),
isDesktop: isDesktop,
),
),
),
if (showSales)
_buildActionCard(
context,
'Sales & Invoices',
'Create invoices',
LucideIcons.receipt,
Colors.green,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const InvoicesListScreen(),
if (showSales)
_buildActionCard(
context,
'Sales & Invoices',
'Create invoices',
LucideIcons.receipt,
Colors.green,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const InvoicesListScreen(),
),
),
isDesktop: isDesktop,
),
),
),
if (showInventory)
_buildActionCard(
context,
'Daily Rates',
'Sync live gold/silver rates',
LucideIcons.refreshCw,
Colors.orange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DailyRatesScreen(),
if (showInventory)
_buildActionCard(
context,
'Daily Rates',
'Sync live gold/silver rates',
LucideIcons.refreshCw,
Colors.amber.shade800,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DailyRatesScreen(),
),
),
isDesktop: isDesktop,
),
_buildActionCard(
context,
'Reports',
'Analytics & Valuation',
LucideIcons.barChart2,
Colors.indigo,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ReportsScreen()),
),
isDesktop: isDesktop,
),
),
_buildActionCard(
context,
'Reports',
'Analytics & Valuation',
LucideIcons.barChart2,
Colors.indigo,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ReportsScreen()),
),
),
],
],
);
},
),
if (showInventory) ...[
const SizedBox(height: 32),
@@ -240,7 +255,7 @@ class BusinessHubScreen extends ConsumerWidget {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
color: Colors.grey.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
),
child: const Center(
@@ -267,13 +282,13 @@ class BusinessHubScreen extends ConsumerWidget {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isOutOfStock
? Colors.red.withOpacity(0.05)
: Colors.orange.withOpacity(0.05),
? Colors.red.withValues(alpha: 0.05)
: Colors.orange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isOutOfStock
? Colors.red.withOpacity(0.2)
: Colors.orange.withOpacity(0.2),
? Colors.red.withValues(alpha: 0.2)
: Colors.orange.withValues(alpha: 0.2),
),
),
child: Row(
@@ -282,8 +297,8 @@ class BusinessHubScreen extends ConsumerWidget {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isOutOfStock
? Colors.red.withOpacity(0.1)
: Colors.orange.withOpacity(0.1),
? Colors.red.withValues(alpha: 0.1)
: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
@@ -365,38 +380,101 @@ class BusinessHubScreen extends ConsumerWidget {
String subtitle,
IconData icon,
Color color,
VoidCallback onTap,
) {
return GestureDetector(
VoidCallback onTap, {
bool isDesktop = false,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.2)),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 12),
Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 16,
child: isDesktop
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
LucideIcons.arrowUpRight,
color: color.withValues(alpha: 0.6),
size: 16,
),
],
),
Text(
subtitle,
style: TextStyle(
color: color.withValues(alpha: 0.8),
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(height: 12),
Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
color: color.withValues(alpha: 0.7),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(color: color.withOpacity(0.7), fontSize: 12),
),
],
),
),
);
}

View File

@@ -24,6 +24,7 @@ class StockLedgerTab extends ConsumerStatefulWidget {
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<InventoryItem>? _items;
bool _isLoading = true;
String _selectedFilter = 'IN_STOCK'; // 'IN_STOCK', 'SOLD', 'ALL'
@override
void initState() {
@@ -61,7 +62,19 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
}
if (_items == null || _items!.isEmpty) {
return const Center(child: Text("No stock available."));
return const Center(child: Text("No stock records found."));
}
final inStockItems = _items!.where((i) => i.status != 'SOLD').toList();
final soldItems = _items!.where((i) => i.status == 'SOLD').toList();
List<InventoryItem> displayItems;
if (_selectedFilter == 'IN_STOCK') {
displayItems = inStockItems;
} else if (_selectedFilter == 'SOLD') {
displayItems = soldItems;
} else {
displayItems = _items!;
}
final categoriesState = ref.watch(productCategoriesProvider);
@@ -86,235 +99,324 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
final double currentRate = commodityRate > 0 ? (commodityRate * purityFactor) : (category?.dailyRate ?? 0.0);
return RefreshIndicator(
onRefresh: _fetchLedger,
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _items!.length,
itemBuilder: (context, index) {
final item = _items![index];
final weight = item.grossWeight ?? 0.0;
final purchaseRate = item.purchaseCost ?? 0.0;
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final isGain = gainLoss >= 0;
return Column(
children: [
// Filter Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
_buildFilterChip('In Stock (${inStockItems.length})', 'IN_STOCK'),
const SizedBox(width: 8),
_buildFilterChip('Sold (${soldItems.length})', 'SOLD'),
const SizedBox(width: 8),
_buildFilterChip('All (${_items!.length})', 'ALL'),
],
),
),
Expanded(
child: displayItems.isEmpty
? Center(
child: Text(
_selectedFilter == 'IN_STOCK'
? 'No active stock available.'
: (_selectedFilter == 'SOLD' ? 'No sold items yet.' : 'No stock records.'),
style: const TextStyle(color: Colors.grey),
),
)
: RefreshIndicator(
onRefresh: _fetchLedger,
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: displayItems.length,
itemBuilder: (context, index) {
final item = displayItems[index];
final isSold = item.status == 'SOLD';
final purchaseOrdersState = ref.watch(purchaseOrdersProvider);
final purchaseOrders = purchaseOrdersState.value ?? [];
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer ${snapshot.data}'},
),
);
double weight = (item.grossWeight != null && item.grossWeight! > 0)
? item.grossWeight!
: ((item.netWeight != null && item.netWeight! > 0)
? item.netWeight!
: (item.fineWeight ?? 0.0));
double purchaseRate = (item.purchaseCost != null && item.purchaseCost! > 0)
? item.purchaseCost!
: ((item.metalCost != null && item.metalCost! > 0 && weight > 0)
? (item.metalCost! / weight)
: 0.0);
// If weight or rate was lost due to earlier db overwrite, recover from purchase order
if ((weight == 0.0 || purchaseRate == 0.0) && item.purchaseRef != null) {
final po = purchaseOrders.where((p) => p.poNumber == item.purchaseRef).firstOrNull;
if (po != null) {
final poItem = po.items.where((i) => i.productId == widget.product.id || (item.huid != null && i.huid == item.huid) || (item.sku != null && i.sku == item.sku)).firstOrNull ?? po.items.firstOrNull;
if (poItem != null) {
if (weight == 0.0) {
weight = poItem.weight ?? (poItem.quantity > 0 ? poItem.quantity : 1.0);
}
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
},
if (purchaseRate == 0.0) {
purchaseRate = poItem.unitPrice;
}
}
}
}
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final isGain = gainLoss >= 0;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSold
? Colors.grey.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.3),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.createdAt != null
? DateFormat('dd MMM yyyy').format(item.createdAt!)
: '',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
color: isSold ? Colors.grey.withValues(alpha: 0.03) : null,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer ${snapshot.data}'},
),
);
}
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.createdAt != null
? DateFormat('dd MMM yyyy').format(item.createdAt!)
: '',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isSold
? Colors.grey.withValues(alpha: 0.15)
: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isSold ? 'SOLD' : 'IN STOCK',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isSold ? Colors.grey.shade700 : Colors.green,
),
),
),
const SizedBox(height: 4),
Text(
'${weight.toStringAsFixed(3)} $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: isSold ? Colors.grey.shade600 : null,
),
),
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Text(
'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
),
],
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isSold ? 'Selling Rate' : 'Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(isSold ? 'Valuation' : 'Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'${isSold ? "Realized " : ""}${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
if (item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'IN STOCK',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
const SizedBox(height: 4),
Text(
'${weight.toStringAsFixed(3)} $unit',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.amber.withValues(alpha: 0.3)),
),
child: Text(
'Purity: ${formatPurity(double.tryParse(item.purity ?? '') ?? purityFactor)}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade900,
),
),
),
],
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
],
),
),
],
),
);
},
),
],
),
),
);
},
),
),
],
);
}
Widget _buildFilterChip(String label, String value) {
final isSelected = _selectedFilter == value;
return InkWell(
onTap: () => setState(() => _selectedFilter = value),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? Colors.blue : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey.shade700,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}

View File

@@ -130,13 +130,42 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
}
Future<void> _pickInvoiceFile() async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (picked != null) {
setState(() => _invoiceFile = picked);
try {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80,
);
if (picked != null) {
setState(() {
_invoiceFile = picked;
_isLoading = true;
});
final bytes = await picked.readAsBytes();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(
bytes,
filename: picked.name.isNotEmpty ? picked.name : 'invoice_${DateTime.now().millisecondsSinceEpoch}.jpg',
),
'type': 'SALES_INVOICE',
});
final uploadResp = await DioClient().dio.post(
'/upload',
data: formData,
);
if (uploadResp.statusCode == 200) {
setState(() {
_invoiceUrl = uploadResp.data['url'];
});
}
}
} catch (e) {
debugPrint('Error uploading sales invoice attachment: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@@ -280,11 +309,12 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
try {
String? finalInvoiceUrl = _invoiceUrl;
if (_invoiceFile != null) {
if (_invoiceFile != null && finalInvoiceUrl == null) {
final bytes = await _invoiceFile!.readAsBytes();
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
_invoiceFile!.path,
filename: _invoiceFile!.name,
'file': MultipartFile.fromBytes(
bytes,
filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
),
'type': 'SALES_INVOICE',
});
@@ -366,12 +396,15 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
}
final invoiceDiscount = double.tryParse(_discountCtrl.text) ?? 0.0;
totalDiscount = invoiceDiscount;
grandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax;
totalDiscount = double.parse(invoiceDiscount.toStringAsFixed(2));
final rawGrandTotal = (subtotal + totalMaking - invoiceDiscount).clamp(0.0, double.infinity) + totalTax;
grandTotal = double.parse(rawGrandTotal.toStringAsFixed(2));
final amountPaid = double.tryParse(_amountPaidCtrl.text) ?? (_isAmountPaidEdited ? 0.0 : grandTotal);
final validAmountPaid = amountPaid > grandTotal ? grandTotal : amountPaid;
final balanceDue = grandTotal - validAmountPaid;
final validAmountPaid = double.parse((amountPaid > grandTotal ? grandTotal : amountPaid).toStringAsFixed(2));
final balanceDue = double.parse((grandTotal - validAmountPaid).clamp(0.0, double.infinity).toStringAsFixed(2));
final isFullyPaid = balanceDue.abs() < 0.01 || validAmountPaid >= grandTotal - 0.01;
final determinedStatus = isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : 'DRAFT');
final invoice = Invoice(
id: widget.existingInvoice?.id,
@@ -379,18 +412,20 @@ class _InvoiceBuilderScreenState extends ConsumerState<InvoiceBuilderScreen> {
invoiceNumber: _invoiceNumberCtrl.text.trim(),
issueDate: _invoiceDate,
dueDate: _dueDate,
subtotal: subtotal,
taxTotal: totalTax,
cgstTotal: totalCgst,
sgstTotal: totalSgst,
igstTotal: totalIgst,
subtotal: double.parse(subtotal.toStringAsFixed(2)),
taxTotal: double.parse(totalTax.toStringAsFixed(2)),
cgstTotal: double.parse(totalCgst.toStringAsFixed(2)),
sgstTotal: double.parse(totalSgst.toStringAsFixed(2)),
igstTotal: double.parse(totalIgst.toStringAsFixed(2)),
discountTotal: totalDiscount,
totalAmount: grandTotal,
amountPaid: validAmountPaid,
paymentMethod: validAmountPaid > 0 ? _paymentMethod : null,
paymentWalletId: validAmountPaid > 0 ? (_selectedWalletId ?? ref.read(walletProvider).value?.firstOrNull?.id) : null,
nextPaymentDate: balanceDue > 0 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
status: widget.existingInvoice?.status ?? (validAmountPaid >= grandTotal ? 'PAID' : (validAmountPaid > 0 ? 'PARTIAL' : 'DRAFT')),
nextPaymentDate: balanceDue > 0.01 ? (_nextPaymentDate ?? DateTime.now().add(const Duration(days: 30))) : null,
status: widget.existingInvoice != null
? (isFullyPaid ? 'PAID' : (validAmountPaid > 0.01 ? 'PARTIAL' : widget.existingInvoice!.status))
: determinedStatus,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
invoiceUrl: finalInvoiceUrl,
isEmi: _isEmi,

View File

@@ -9,6 +9,7 @@ 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 'package:printing/printing.dart';
import '../../inventory/providers/products_provider.dart';
import '../../inventory/providers/product_categories_provider.dart';
import '../domain/invoice.dart';
@@ -147,15 +148,15 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: _getStatusColor(latestInvoice.status).withValues(alpha: 0.15),
color: _getStatusColor(_getEffectiveStatus(latestInvoice)).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
latestInvoice.status,
_getEffectiveStatus(latestInvoice),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _getStatusColor(latestInvoice.status),
color: _getStatusColor(_getEffectiveStatus(latestInvoice)),
),
),
),
@@ -464,6 +465,17 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
);
}
String _getEffectiveStatus(Invoice invoice) {
final balanceDue = invoice.totalAmount - invoice.amountPaid;
if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) {
return 'PAID';
}
if (invoice.amountPaid > 0.01) {
return 'PARTIAL';
}
return invoice.status.isNotEmpty ? invoice.status : 'DRAFT';
}
Color _getStatusColor(String status) {
switch (status) {
case 'PAID':
@@ -542,60 +554,147 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final customer = customers.where((c) => c.id == latestInvoice.customerId).firstOrNull;
final business = ref.read(businessProfileProvider).value;
final formatCurrency = NumberFormat.currency(symbol: '', decimalDigits: 2);
final products = ref.read(productsProvider).value ?? [];
final categories = ref.read(productCategoriesProvider).value ?? [];
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final formatDate = DateFormat('dd MMM yyyy');
final font = await PdfGoogleFonts.robotoRegular();
final boldFont = await PdfGoogleFonts.robotoBold();
final businessStateId = business?.stateId;
final customerStateId = customer?.stateId;
final isSameState = businessStateId != null && customerStateId != null
? (businessStateId == customerStateId)
: true;
// Group items by product ID if available, or itemize
final Map<int, List<InvoiceItem>> groupedItems = {};
final List<InvoiceItem> ungroupedItems = [];
for (final item in latestInvoice.items) {
if (item.productId != null) {
groupedItems.putIfAbsent(item.productId!, () => []).add(item);
} else {
ungroupedItems.add(item);
}
}
final pdf = pw.Document();
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont(
base: font,
bold: boldFont,
),
margin: const pw.EdgeInsets.all(28),
build: (pw.Context context) {
return [
// Header with Business Details & TAX INVOICE title
// 1. Header with Business Details & TAX INVOICE title
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
business?.businessName ?? 'KIFI JEWELLERS',
style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900),
),
pw.SizedBox(height: 4),
if (business?.address != null)
pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 10)),
if (business?.contactNumber != null)
pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 10)),
if (business?.gstin != null)
pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
],
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
business?.businessName ?? 'KIFI JEWELLERS',
style: pw.TextStyle(
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
pw.SizedBox(height: 4),
if (business?.address != null && business!.address!.isNotEmpty)
pw.Text(
business.address!,
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty)
pw.Text(
'Phone: ${business.contactNumber}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
if (business?.gstin != null && business!.gstin!.isNotEmpty)
pw.Text(
'GSTIN: ${business.gstin}',
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue800,
),
),
],
),
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
'TAX INVOICE',
style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold, color: PdfColors.blue900),
style: pw.TextStyle(
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
pw.SizedBox(height: 6),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: pw.BoxDecoration(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green100
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange100 : PdfColors.grey200),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
border: pw.Border.all(
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green700
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange700 : PdfColors.grey500),
width: 0.5,
),
),
child: pw.Text(
_getEffectiveStatus(latestInvoice),
style: pw.TextStyle(
fontSize: 9,
fontWeight: pw.FontWeight.bold,
color: _getEffectiveStatus(latestInvoice) == 'PAID'
? PdfColors.green900
: (_getEffectiveStatus(latestInvoice) == 'PARTIAL' ? PdfColors.orange900 : PdfColors.grey800),
),
),
),
pw.SizedBox(height: 6),
pw.Text(
'Invoice #: ${latestInvoice.invoiceNumber}',
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
),
pw.Text(
'Date: ${formatDate.format(latestInvoice.issueDate)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
pw.SizedBox(height: 4),
pw.Text('Invoice #: ${latestInvoice.invoiceNumber}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 10)),
if (latestInvoice.dueDate != null)
pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 10)),
pw.Text(
'Due Date: ${formatDate.format(latestInvoice.dueDate!)}',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
],
),
],
),
pw.SizedBox(height: 20),
pw.SizedBox(height: 18),
// Bill To Box
// 2. Bill To Box (Customer Details)
pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
@@ -606,12 +705,31 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('BILL TO (CUSTOMER):', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
pw.Text(
'BILL TO (CUSTOMER):',
style: pw.TextStyle(
fontSize: 8.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.grey700,
),
),
pw.SizedBox(height: 2),
pw.Text(customer?.name ?? 'Walk-in Customer', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
if (customer?.phone != null) pw.Text('Phone: ${customer!.phone}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.address != null) pw.Text('Address: ${customer!.address}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.gstin != null) pw.Text('GSTIN: ${customer!.gstin}', style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)),
pw.Text(
customer?.name ?? 'Walk-in Customer',
style: pw.TextStyle(
fontSize: 12,
fontWeight: pw.FontWeight.bold,
),
),
if (customer?.phone != null && customer!.phone!.isNotEmpty)
pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.address != null && customer!.address!.isNotEmpty)
pw.Text('Address: ${customer.address}', style: const pw.TextStyle(fontSize: 9)),
if (customer?.gstin != null && customer!.gstin!.isNotEmpty)
pw.Text(
'GSTIN: ${customer.gstin}',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold),
),
],
),
),
@@ -620,30 +738,28 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
),
pw.SizedBox(height: 16),
// Table of Items with HUID, SKU, Making Charges, etc.
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 9),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900),
cellStyle: const pw.TextStyle(fontSize: 8.5),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerLeft,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
5: pw.Alignment.centerRight,
6: pw.Alignment.centerRight,
},
headers: ['Item / Particulars', 'HSN / HUID', 'Weight', 'Rate', 'Making Chg', 'GST', 'Total'],
data: latestInvoice.items.map((item) {
// 3. Product Groups with Full Details (Category, Purity, HSN, HUID, Weight, Making Charges, GST Breakup)
...groupedItems.entries.map((entry) {
final productId = entry.key;
final group = entry.value;
final product = products.where((p) => p.id == productId).firstOrNull;
final category = categories.where((c) => c.id == product?.categoryId).firstOrNull;
final productName = product?.name ?? group.first.productName ?? 'Item $productId';
final categoryName = category?.name ?? group.first.categoryName ?? 'General Category';
final skuStr = product?.sku ?? group.first.sku;
final purityStr = product?.purityFactor != null
? '${(product!.purityFactor! * 100).toStringAsFixed(1)}%'
: (category?.purityFactor != null ? '${(category!.purityFactor! * 100).toStringAsFixed(1)}%' : '0.916');
final hsnStr = product?.hsnCode ?? group.first.hsnCode ?? category?.defaultHsn ?? '-';
double groupSubtotal = 0;
double groupMaking = 0;
double groupTax = 0;
for (final item in group) {
final weight = item.weight ?? item.quantity;
final metalAmt = weight * item.unitPrice;
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = metalAmt * (item.makingCharge / 100.0);
@@ -652,129 +768,481 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
} else {
makingAmt = weight * item.makingCharge;
}
groupSubtotal += metalAmt;
groupMaking += makingAmt;
groupTax += (item.cgst + item.sgst + item.igst);
}
String particulars = item.productName ?? item.description ?? 'Item';
if (item.sku != null && item.sku!.isNotEmpty) particulars += '\nSKU: ${item.sku}';
if (item.categoryName != null) particulars += ' (${item.categoryName})';
final groupTotal = group.fold(0.0, (sum, i) => sum + i.total);
final gstRate = group.first.taxRate > 0 ? group.first.taxRate : (product?.gstRate ?? (category?.defaultGst ?? 0.0));
String hsnHuid = 'HSN: ${item.hsnCode ?? '-'}';
if (item.huid != null && item.huid!.isNotEmpty) hsnHuid += '\nHUID: ${item.huid}';
return pw.Container(
margin: const pw.EdgeInsets.only(bottom: 14),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey300, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
// Product Title & Badges
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: const pw.BoxDecoration(
color: PdfColors.grey200,
borderRadius: pw.BorderRadius.vertical(top: pw.Radius.circular(5.5)),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
child: pw.Text(
productName,
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.black,
),
),
),
pw.Wrap(
spacing: 6,
runSpacing: 4,
children: [
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.orange50,
border: pw.Border.all(color: PdfColors.orange400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
categoryName,
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.deepOrange900,
),
),
),
if (skuStr != null && skuStr.isNotEmpty)
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey500, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'SKU: $skuStr',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey800),
),
),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.amber50,
border: pw.Border.all(color: PdfColors.amber600, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'Purity: $purityStr',
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.brown800,
),
),
),
if (hsnStr != '-')
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: pw.BoxDecoration(
color: PdfColors.purple50,
border: pw.Border.all(color: PdfColors.purple400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Text(
'HSN: $hsnStr',
style: pw.TextStyle(
fontSize: 7.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.purple900,
),
),
),
],
),
],
),
),
return [
particulars,
hsnHuid,
'${weight.toStringAsFixed(3)} g',
formatCurrency.format(item.unitPrice),
formatCurrency.format(makingAmt),
'${item.taxRate.toStringAsFixed(1)}%',
formatCurrency.format(item.total),
];
}).toList(),
),
pw.SizedBox(height: 16),
// Items Table for this Product
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
),
headerStyle: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
fontSize: 8,
),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900),
cellStyle: const pw.TextStyle(fontSize: 8),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
4: pw.Alignment.centerRight,
5: pw.Alignment.centerRight,
},
headers: [
'Item / HUID',
'Weight / Qty',
'Rate',
'Making Chg',
'Other Chg',
'Total Amt',
],
data: group.map((item) {
String particular = '';
if (item.huid != null && item.huid!.isNotEmpty) {
particular = 'HUID: ${item.huid}';
} else if (item.sku != null && item.sku!.isNotEmpty) {
particular = 'SKU: ${item.sku}';
} else {
particular = 'Standard Item';
}
// Summary Section
final weight = item.weight ?? item.quantity;
final weightQty = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(item.weight!.truncateToDouble() == item.weight ? 0 : 3)} g'
: '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 0)} pcs';
final rateUnit = item.weight != null && item.weight! > 0 ? '/ g' : '/ pc';
double makingAmt = 0.0;
if (item.makingChargesType == 'PERCENTAGE') {
makingAmt = (weight * item.unitPrice) * (item.makingCharge / 100.0);
} else if (item.makingChargesType == 'PER_PIECE') {
makingAmt = item.makingCharge;
} else {
makingAmt = weight * item.makingCharge;
}
return [
particular,
weightQty,
'${formatCurrency.format(item.unitPrice)} $rateUnit',
makingAmt > 0 ? formatCurrency.format(makingAmt) : '-',
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
formatCurrency.format(item.total),
];
}).toList(),
),
// Group Subtotal & GST Calculation Box
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: const pw.BoxDecoration(
color: PdfColors.blue50,
borderRadius: pw.BorderRadius.vertical(bottom: pw.Radius.circular(5.5)),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Metal Subtotal: ${formatCurrency.format(groupSubtotal)} | Making: ${formatCurrency.format(groupMaking)}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'Taxable: ${formatCurrency.format(groupSubtotal + groupMaking)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
if (gstRate > 0) ...[
pw.SizedBox(height: 2),
if (isSameState) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'CGST (${(gstRate / 2).toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax / 2)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'SGST (${(gstRate / 2).toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax / 2)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
] else ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'IGST (${gstRate.toStringAsFixed(1)}%):',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
pw.Text(
'+ ${formatCurrency.format(groupTax)}',
style: const pw.TextStyle(fontSize: 8),
),
],
),
],
],
pw.Divider(color: PdfColors.grey300, height: 8),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Group Total:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.Text(
formatCurrency.format(groupTotal),
style: pw.TextStyle(
fontSize: 8.5,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
],
),
],
),
),
],
),
);
}),
// 3b. Ungrouped Items (if any)
if (ungroupedItems.isNotEmpty) ...[
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white, fontSize: 8),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue900),
cellStyle: const pw.TextStyle(fontSize: 8),
headers: ['Particulars', 'Weight / Qty', 'Rate', 'Making Chg', 'Other Chg', 'Total'],
data: ungroupedItems.map((item) {
final weightQty = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs';
return [
item.productName ?? item.description ?? 'Item',
weightQty,
formatCurrency.format(item.unitPrice),
item.makingCharge > 0 ? formatCurrency.format(item.makingCharge) : '-',
item.otherCharges > 0 ? formatCurrency.format(item.otherCharges) : '-',
formatCurrency.format(item.total),
];
}).toList(),
),
pw.SizedBox(height: 12),
],
pw.SizedBox(height: 12),
// 4. Overall Invoice Summary
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Container(
width: 240,
// Left Side: Notes & Declarations
pw.Expanded(
flex: 3,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 10)),
pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 10)),
],
),
if (latestInvoice.cgstTotal > 0 && latestInvoice.sgstTotal > 0) ...[
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('CGST:', style: const pw.TextStyle(fontSize: 9)),
pw.Text(formatCurrency.format(latestInvoice.cgstTotal), style: const pw.TextStyle(fontSize: 9)),
],
if (latestInvoice.notes != null && latestInvoice.notes!.isNotEmpty) ...[
pw.Text(
'Notes:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('SGST:', style: const pw.TextStyle(fontSize: 9)),
pw.Text(formatCurrency.format(latestInvoice.sgstTotal), style: const pw.TextStyle(fontSize: 9)),
],
),
] else if (latestInvoice.igstTotal > 0) ...[
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('IGST:', style: const pw.TextStyle(fontSize: 9)),
pw.Text(formatCurrency.format(latestInvoice.igstTotal), style: const pw.TextStyle(fontSize: 9)),
],
pw.Text(
latestInvoice.notes!,
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey800),
),
pw.SizedBox(height: 8),
],
if (latestInvoice.discountTotal > 0) ...[
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Discount:', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)),
pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 9, color: PdfColors.green700)),
],
),
],
pw.Divider(color: PdfColors.grey400, height: 10),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)),
pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 13)),
],
pw.Text(
'Terms & Conditions:',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold),
),
pw.SizedBox(height: 4),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)),
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 10, color: PdfColors.green800)),
],
pw.SizedBox(height: 2),
pw.Text(
'1. Goods once sold are subject to standard hallmarking certification.',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700),
),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)),
pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11, color: PdfColors.red800)),
],
pw.Text(
'2. Weight & purity tested under industry standard electronic balance.',
style: const pw.TextStyle(fontSize: 7.5, color: PdfColors.grey700),
),
],
),
),
pw.SizedBox(width: 20),
// Right Side: Grand Total & Tax Summary Box
pw.Expanded(
flex: 2,
child: pw.Container(
padding: const pw.EdgeInsets.all(10),
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
),
child: pw.Column(
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Metal Subtotal:', style: const pw.TextStyle(fontSize: 8.5)),
pw.Text(formatCurrency.format(latestInvoice.subtotal), style: const pw.TextStyle(fontSize: 8.5)),
],
),
if (latestInvoice.cgstTotal > 0 || latestInvoice.sgstTotal > 0 || latestInvoice.igstTotal > 0 || latestInvoice.taxTotal > 0) ...[
pw.SizedBox(height: 4),
if (isSameState) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('CGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text('+ ${formatCurrency.format(latestInvoice.cgstTotal > 0 ? latestInvoice.cgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)),
],
),
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('SGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text('+ ${formatCurrency.format(latestInvoice.sgstTotal > 0 ? latestInvoice.sgstTotal : (latestInvoice.taxTotal / 2))}', style: const pw.TextStyle(fontSize: 8.5)),
],
),
] else ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('IGST Total:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.Text('+ ${formatCurrency.format(latestInvoice.igstTotal > 0 ? latestInvoice.igstTotal : latestInvoice.taxTotal)}', style: const pw.TextStyle(fontSize: 8.5)),
],
),
],
],
if (latestInvoice.discountTotal > 0) ...[
pw.SizedBox(height: 4),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Discount:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)),
pw.Text('- ${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green700)),
],
),
],
pw.Divider(color: PdfColors.grey400, height: 10),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Grand Total:',
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
pw.Text(
formatCurrency.format(latestInvoice.totalAmount),
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blue900,
),
),
],
),
if (latestInvoice.amountPaid > 0) ...[
pw.SizedBox(height: 6),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Amount Received:', style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)),
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.green800)),
],
),
pw.SizedBox(height: 2),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'Balance Due:',
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800),
),
pw.Text(
formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid),
style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.red800),
),
],
),
],
],
),
),
),
],
),
pw.SizedBox(height: 30),
pw.SizedBox(height: 24),
// Terms & Signatory
pw.Divider(color: PdfColors.grey300),
// 5. Signatures
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Terms & Conditions:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 8)),
pw.Text('1. Goods once sold are subject to standard hallmarking certification.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)),
pw.Text('2. Weight & purity tested under industry standard electronic balance.', style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey700)),
pw.SizedBox(height: 25),
pw.Text(
'Customer\'s Signature',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('For ${business?.businessName ?? "KIFI JEWELLERS"}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 9)),
pw.SizedBox(height: 24),
pw.Text('Authorized Signatory', style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
pw.SizedBox(height: 25),
pw.Text(
'For ${business?.businessName ?? "KIFI JEWELLERS"} (Authorized Signatory)',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey700),
),
],
),
],
@@ -784,13 +1252,19 @@ class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
),
);
final directory = await getTemporaryDirectory();
final pdfPath = await File('${directory.path}/TaxInvoice_$invoiceNumber.pdf').create();
await pdfPath.writeAsBytes(await pdf.save());
final pdfBytes = await pdf.save();
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Tax Invoice $invoiceNumber');
// Cross-platform PDF sharing / printing
await Printing.sharePdf(
bytes: pdfBytes,
filename: 'TaxInvoice_${invoiceNumber.replaceAll('/', '_')}.pdf',
);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e')));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error sharing PDF: $e')),
);
}
}
}
}

View File

@@ -5,7 +5,6 @@ import 'package:intl/intl.dart';
import '../providers/invoices_provider.dart';
import '../domain/invoice.dart';
import '../providers/customers_provider.dart';
import '../domain/customer.dart';
import 'invoice_builder_screen.dart';
import 'invoice_details_screen.dart';
@@ -22,9 +21,12 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
String _getStatusLabel(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return 'EMI';
if (invoice.status == 'PAID' || invoice.amountPaid >= invoice.totalAmount && invoice.totalAmount > 0) return 'PAID';
if (invoice.amountPaid > 0) return 'PARTIAL';
return invoice.status;
final balanceDue = invoice.totalAmount - invoice.amountPaid;
if (invoice.status == 'PAID' || balanceDue.abs() < 0.01 || (invoice.totalAmount > 0 && invoice.amountPaid >= invoice.totalAmount - 0.01)) {
return 'PAID';
}
if (invoice.amountPaid > 0.01) return 'PARTIAL';
return invoice.status.isNotEmpty ? invoice.status : 'DRAFT';
}
Color _getStatusColor(Invoice invoice) {
@@ -237,12 +239,72 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
),
),
if (invoice.items.isNotEmpty)
Text(
'${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${invoice.items.length} ${invoice.items.length == 1 ? "item" : "items"}',
style: const TextStyle(fontSize: 11, color: Colors.blue, fontWeight: FontWeight.w600),
),
),
],
),
// Row 2.5: Sold Items Details
if (invoice.items.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...invoice.items.take(3).map((item) {
final weightStr = item.weight != null && item.weight! > 0
? '${item.weight!.toStringAsFixed(3)} g'
: '${item.quantity.toInt()} pcs';
final name = item.productName ?? item.description ?? 'Jewellery Item';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
children: [
Icon(LucideIcons.sparkles, size: 12, color: Colors.amber.shade700),
const SizedBox(width: 6),
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
weightStr,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600),
),
],
),
);
}),
if (invoice.items.length > 3)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'+ ${invoice.items.length - 3} more items',
style: TextStyle(fontSize: 11, color: Colors.blue.shade600, fontWeight: FontWeight.w500),
),
),
],
),
),
],
const SizedBox(height: 10),
// Row 3: Date & Total Amount

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../../core/widgets/premium_text_field.dart';
import '../../../transactions/providers/providers.dart';
import '../../providers/invoices_provider.dart';
import '../../providers/customers_provider.dart';
import '../../domain/invoice.dart';
class ReceivePaymentSheet extends ConsumerStatefulWidget {
@@ -17,20 +19,43 @@ class ReceivePaymentSheet extends ConsumerStatefulWidget {
}
class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
final TextEditingController _amountCtrl = TextEditingController();
final _formKey = GlobalKey<FormState>();
late TextEditingController _amountCtrl;
String _paymentMethod = 'Cash';
int? _selectedWalletId;
bool _isLoading = false;
final List<Map<String, dynamic>> _methods = [
{'key': 'Cash', 'label': 'Cash', 'icon': LucideIcons.banknote},
{'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
{'key': 'Bank Transfer', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
{'key': 'Card', 'label': 'Card', 'icon': LucideIcons.creditCard},
];
@override
void initState() {
super.initState();
final balance = widget.invoice.totalAmount - widget.invoice.amountPaid;
_amountCtrl.text = balance.toStringAsFixed(2);
_amountCtrl = TextEditingController(
text: balance > 0 ? balance.toStringAsFixed(2) : '0.00',
);
}
@override
void dispose() {
_amountCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: '');
final balanceDue = (widget.invoice.totalAmount - widget.invoice.amountPaid).clamp(0.0, double.infinity);
final customersState = ref.watch(customersProvider);
final customer = customersState.value?.where((c) => c.id == widget.invoice.customerId).firstOrNull;
final walletsState = ref.watch(walletProvider);
final allWallets = walletsState.value ?? [];
@@ -49,165 +74,343 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
}
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
top: 24,
left: 24,
right: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
top: 12,
left: 20,
right: 20,
),
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) {
if (val != null) {
setState(() {
_paymentMethod = val;
_selectedWalletId = null;
});
}
},
),
),
),
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),
child: SafeArea(
top: false,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
),
onPressed: _isLoading
? null
: () async {
final amount = double.tryParse(_amountCtrl.text) ?? 0;
if (amount <= 0) return;
const SizedBox(height: 14),
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,
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.arrowDownLeft, size: 20, color: Colors.blue),
),
const SizedBox(width: 12),
const Text(
'Receive Payment',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold),
),
],
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
style: IconButton.styleFrom(
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100,
),
)
: const Text(
'Confirm Payment',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
// Invoice Summary Card
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customer?.name ?? 'Customer',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'Invoice #${widget.invoice.invoiceNumber}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Balance Due',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
formatCurrency.format(balanceDue),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
],
),
],
),
),
const SizedBox(height: 18),
// Amount Field
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Payment Amount',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
if (balanceDue > 0)
InkWell(
onTap: () {
setState(() {
_amountCtrl.text = balanceDue.toStringAsFixed(2);
});
},
child: Text(
'Receive Full (${formatCurrency.format(balanceDue)})',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade600,
),
),
),
],
),
const SizedBox(height: 8),
PremiumTextField(
controller: _amountCtrl,
labelText: 'Amount Paid (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee, size: 18),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Required';
final parsed = double.tryParse(val);
if (parsed == null || parsed <= 0) return 'Invalid amount';
return null;
},
),
const SizedBox(height: 16),
// Payment Method Chips
const Text(
'Payment Method',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _methods.map((m) {
final isSelected = _paymentMethod == m['key'];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
avatar: Icon(
m['icon'] as IconData,
size: 15,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade700),
),
label: Text(m['label'] as String),
labelStyle: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade800),
),
selected: isSelected,
selectedColor: Colors.blue.shade600,
backgroundColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isSelected ? Colors.blue.shade600 : (isDark ? Colors.white10 : Colors.grey.shade300),
width: 0.8,
),
),
onSelected: (_) {
setState(() {
_paymentMethod = m['key'] as String;
_selectedWalletId = null;
});
},
),
);
}).toList(),
),
),
const SizedBox(height: 16),
// Receive Into Wallet
if (wallets.isNotEmpty) ...[
const Text(
'Receive Into Wallet',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white12 : Colors.grey.shade300,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _selectedWalletId,
isExpanded: true,
dropdownColor: isDark ? const Color(0xFF1E293B) : Colors.white,
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: 22),
] else
const SizedBox(height: 8),
// Submit Button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade600,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: _isLoading
? null
: () async {
if (!_formKey.currentState!.validate()) return;
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,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Payment of ₹${amount.toStringAsFixed(2)} received successfully!'),
backgroundColor: Colors.green.shade700,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
},
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.checkCircle2, size: 18),
SizedBox(width: 8),
Text(
'Confirm Payment',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}

View File

@@ -114,10 +114,11 @@ class _AddVendorSheetState extends ConsumerState<AddVendorSheet> {
try {
String? photoUrl = widget.vendor?.photoUrl;
if (_photo != null && _token != null) {
final bytes = await _photo!.readAsBytes();
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
_photo!.path,
filename: _photo!.name,
'file': MultipartFile.fromBytes(
bytes,
filename: _photo!.name.isNotEmpty ? _photo!.name : 'vendor.jpg',
),
'type': 'VENDOR',
});

View File

@@ -3,10 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart';
import '../domain/purchase_order.dart';
import '../domain/purchase_payment.dart';
import '../providers/purchase_orders_provider.dart';
import '../providers/vendors_provider.dart';
class PayVendorSheet extends ConsumerStatefulWidget {
final PurchaseOrder po;
@@ -22,15 +22,15 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
late TextEditingController _amountCtrl;
final TextEditingController _notesCtrl = TextEditingController();
DateTime _paymentDate = DateTime.now();
String? _selectedMethod;
String _selectedMethod = 'CASH';
bool _isLoading = false;
final List<String> _paymentMethods = [
'CASH',
'BANK_TRANSFER',
'UPI',
'CARD',
'CHEQUE',
final List<Map<String, dynamic>> _methods = [
{'key': 'CASH', 'label': 'Cash', 'icon': LucideIcons.banknote},
{'key': 'UPI', 'label': 'UPI', 'icon': LucideIcons.smartphone},
{'key': 'BANK_TRANSFER', 'label': 'Bank Transfer', 'icon': LucideIcons.building2},
{'key': 'CARD', 'label': 'Card', 'icon': LucideIcons.creditCard},
{'key': 'CHEQUE', 'label': 'Cheque', 'icon': LucideIcons.fileText},
];
@override
@@ -38,7 +38,7 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
super.initState();
final balance = widget.po.totalAmount - widget.po.amountPaid;
_amountCtrl = TextEditingController(
text: balance > 0 ? balance.toStringAsFixed(2) : '0',
text: balance > 0 ? balance.toStringAsFixed(2) : '0.00',
);
}
@@ -51,9 +51,11 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
Future<void> _savePayment() async {
if (!_formKey.currentState!.validate()) return;
if (_selectedMethod == null) {
final amount = double.tryParse(_amountCtrl.text);
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a payment method')),
const SnackBar(content: Text('Please enter a valid amount')),
);
return;
}
@@ -61,10 +63,10 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
setState(() => _isLoading = true);
try {
final payment = PurchasePayment(
amount: double.parse(_amountCtrl.text),
amount: amount,
paymentMethod: _selectedMethod,
paymentDate: _paymentDate,
notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text,
notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
);
await ref
@@ -74,14 +76,17 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
if (mounted) {
Navigator.pop(context, true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment recorded successfully')),
SnackBar(
content: Text('Payment of ₹${amount.toStringAsFixed(2)} recorded successfully'),
backgroundColor: Colors.green.shade700,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text('Error recording payment: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
@@ -90,101 +95,325 @@ class _PayVendorSheetState extends ConsumerState<PayVendorSheet> {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
final isDark = Theme.of(context).brightness == Brightness.dark;
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('dd MMM yyyy');
final balanceDue = (widget.po.totalAmount - widget.po.amountPaid).clamp(0.0, double.infinity);
final vendorsState = ref.watch(vendorsProvider);
final vendor = vendorsState.value?.where((v) => v.id == widget.po.vendorId).firstOrNull;
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
left: 20,
right: 20,
top: 12,
),
child: SafeArea(
top: false,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Record Payment',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
// Drag Handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: isDark ? Colors.white24 : Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
const SizedBox(height: 14),
// Title & Close Button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.indianRupee, size: 20, color: Colors.green),
),
const SizedBox(width: 12),
const Text(
'Record Payment',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold),
),
],
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
style: IconButton.styleFrom(
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100,
),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
// PO Summary & Balance Card
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark ? Colors.white10 : Colors.grey.shade200,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
vendor?.name ?? 'Vendor',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'PO #${widget.po.poNumber}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Balance Due',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
formatCurrency.format(balanceDue),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.deepOrange,
),
),
],
),
],
),
),
const SizedBox(height: 18),
// Amount Field
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Payment Amount',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
if (balanceDue > 0)
InkWell(
onTap: () {
setState(() {
_amountCtrl.text = balanceDue.toStringAsFixed(2);
});
},
child: Text(
'Pay Full (${formatCurrency.format(balanceDue)})',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.blue.shade600,
),
),
),
],
),
const SizedBox(height: 8),
PremiumTextField(
controller: _amountCtrl,
labelText: 'Amount Paid (₹)',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
prefixIcon: const Icon(LucideIcons.indianRupee, size: 18),
validator: (val) {
if (val == null || val.trim().isEmpty) return 'Required';
final parsed = double.tryParse(val);
if (parsed == null || parsed <= 0) return 'Invalid amount';
return null;
},
),
const SizedBox(height: 16),
// Payment Method Chips
const Text(
'Payment Method',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _methods.map((m) {
final isSelected = _selectedMethod == m['key'];
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
avatar: Icon(
m['icon'] as IconData,
size: 15,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade700),
),
label: Text(m['label'] as String),
labelStyle: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : (isDark ? Colors.grey.shade300 : Colors.grey.shade800),
),
selected: isSelected,
selectedColor: Colors.blue.shade600,
backgroundColor: isDark ? Colors.white.withValues(alpha: 0.05) : Colors.grey.shade100,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isSelected ? Colors.blue.shade600 : (isDark ? Colors.white10 : Colors.grey.shade300),
width: 0.8,
),
),
onSelected: (_) {
setState(() => _selectedMethod = m['key'] as String);
},
),
);
}).toList(),
),
),
const SizedBox(height: 16),
// Payment Date
const Text(
'Payment Date',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final d = await showDatePicker(
context: context,
initialDate: _paymentDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) setState(() => _paymentDate = d);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: isDark ? Colors.white.withValues(alpha: 0.04) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white12 : Colors.grey.shade300,
),
),
child: Row(
children: [
Icon(LucideIcons.calendar, size: 18, color: Colors.grey.shade600),
const SizedBox(width: 10),
Text(
formatDate.format(_paymentDate),
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
const Spacer(),
Icon(LucideIcons.chevronDown, size: 16, color: Colors.grey.shade500),
],
),
),
),
const SizedBox(height: 16),
// Notes Field
PremiumTextField(
controller: _notesCtrl,
labelText: 'Notes / Reference (Optional)',
prefixIcon: const Icon(LucideIcons.stickyNote, size: 18),
maxLines: 2,
),
const SizedBox(height: 22),
// Submit Button
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _isLoading ? null : _savePayment,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.checkCircle2, size: 18),
SizedBox(width: 8),
Text(
'Confirm Payment',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
const SizedBox(height: 16),
PremiumTextField(
controller: _amountCtrl,
labelText: 'Amount Paid',
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
prefixIcon: const Icon(LucideIcons.indianRupee),
validator: (val) {
if (val == null || val.isEmpty) return 'Required';
if (double.tryParse(val) == null) return 'Invalid amount';
return null;
},
),
const SizedBox(height: 16),
SmartSearchDropdown<String>(
labelText: 'Payment Method',
hintText: 'Select Method',
value: _selectedMethod,
items: _paymentMethods,
itemAsString: (val) => val.replaceAll('_', ' '),
onChanged: (val) => setState(() => _selectedMethod = val),
),
const SizedBox(height: 16),
InkWell(
onTap: () async {
final d = await showDatePicker(
context: context,
initialDate: _paymentDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) setState(() => _paymentDate = d);
},
child: InputDecorator(
decoration: const InputDecoration(labelText: 'Payment Date'),
child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)),
),
),
const SizedBox(height: 16),
PremiumTextField(
controller: _notesCtrl,
labelText: 'Notes (Optional)',
maxLines: 2,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _savePayment,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Save Payment'),
),
),
const SizedBox(height: 24),
],
),
),
),
);

View File

@@ -29,8 +29,12 @@ String _resolveImageUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
final baseUrl = DioClient().dio.options.baseUrl;
return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}';
final base = DioClient().dio.options.baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
if (cleanPath.startsWith('api/kifi-v2/upload/view') || cleanPath.startsWith('upload/view')) {
return '$base/${cleanPath.replaceFirst('api/kifi-v2/', '')}';
}
return '$base/upload/view?path=${Uri.encodeComponent(cleanPath)}';
}
class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget {
@@ -138,10 +142,11 @@ class _PurchaseOrderBuilderScreenState
String? finalInvoiceUrl = _invoiceUrl;
if (_invoiceFile != null) {
final bytes = await _invoiceFile!.readAsBytes();
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
_invoiceFile!.path,
filename: _invoiceFile!.name,
'file': MultipartFile.fromBytes(
bytes,
filename: _invoiceFile!.name.isNotEmpty ? _invoiceFile!.name : 'invoice.jpg',
),
'type': 'PO_INVOICE',
});
@@ -171,18 +176,24 @@ class _PurchaseOrderBuilderScreenState
for (var item in _items) {
String? finalPhotoUrl = item.photoUrl;
if (item.localPhotoPath != null && item.photoUrl == null) {
if (finalPhotoUrl == null && item.localPhotoPath != null) {
try {
final itemFile = File(item.localPhotoPath!);
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(itemFile.path, filename: itemFile.path.split('/').last),
});
final uploadResp = await DioClient().dio.post('/upload', data: formData);
if (uploadResp.statusCode == 200) {
finalPhotoUrl = uploadResp.data['url'] as String?;
if (!kIsWeb) {
final itemFile = File(item.localPhotoPath!);
if (await itemFile.exists()) {
final bytes = await itemFile.readAsBytes();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: itemFile.path.split('/').last),
'type': 'PO_ITEM_PHOTO',
});
final uploadResp = await DioClient().dio.post('/upload', data: formData);
if (uploadResp.statusCode == 200) {
finalPhotoUrl = uploadResp.data['url'] as String?;
}
}
}
} catch (e) {
debugPrint('Item photo upload failed: $e');
debugPrint('Item photo upload fallback failed: $e');
}
}
@@ -1335,12 +1346,48 @@ class _POItemRowState extends State<_POItemRow> {
widget.onChanged(updated);
}
bool _isUploadingPhoto = false;
Future<void> _pickPhoto() async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70);
if (picked != null) {
final updated = widget.item.copyWith(localPhotoPath: picked.path);
widget.onChanged(updated);
try {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 75);
if (picked != null) {
setState(() => _isUploadingPhoto = true);
final bytes = await picked.readAsBytes();
final fileName = picked.name.isNotEmpty
? picked.name
: 'photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: fileName),
'type': 'PO_ITEM_PHOTO',
});
final uploadResp = await DioClient().dio.post('/upload', data: formData);
String? serverUrl;
if (uploadResp.statusCode == 200) {
serverUrl = uploadResp.data['url'] as String?;
}
final updated = widget.item.copyWith(
localPhotoPath: picked.path,
photoUrl: serverUrl ?? widget.item.photoUrl,
);
widget.onChanged(updated);
}
} catch (e) {
debugPrint('Error picking/uploading item photo: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload image: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isUploadingPhoto = false);
}
}
}
@@ -1358,17 +1405,8 @@ class _POItemRowState extends State<_POItemRow> {
children: [
GestureDetector(
onTap: () {
if (widget.item.localPhotoPath != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [kIsWeb ? NetworkImage(widget.item.localPhotoPath!) : FileImage(File(widget.item.localPhotoPath!))],
initialIndex: 0,
),
),
);
} else if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) {
if (_isUploadingPhoto) return;
if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
@@ -1378,11 +1416,25 @@ class _POItemRowState extends State<_POItemRow> {
),
),
);
} else if (widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AttachmentGalleryScreen(
images: [
kIsWeb
? NetworkImage(widget.item.localPhotoPath!)
: FileImage(File(widget.item.localPhotoPath!)) as ImageProvider,
],
initialIndex: 0,
),
),
);
} else {
_pickPhoto();
}
},
onLongPress: _pickPhoto,
onLongPress: _isUploadingPhoto ? null : _pickPhoto,
child: Container(
width: 52,
height: 52,
@@ -1394,21 +1446,36 @@ class _POItemRowState extends State<_POItemRow> {
child: Stack(
children: [
Positioned.fill(
child: widget.item.localPhotoPath != null
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: kIsWeb
? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover)
: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover),
)
: widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(_resolveImageUrl(widget.item.photoUrl!), fit: BoxFit.cover),
child: _isUploadingPhoto
? const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)),
: widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.network(
_resolveImageUrl(widget.item.photoUrl!),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(LucideIcons.imageOff, color: Colors.grey, size: 22),
),
)
: widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(7),
child: kIsWeb
? Image.network(widget.item.localPhotoPath!, fit: BoxFit.cover)
: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover),
)
: const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)),
),
if (widget.item.localPhotoPath != null || (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty))
if (!_isUploadingPhoto &&
((widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) ||
(widget.item.localPhotoPath != null && widget.item.localPhotoPath!.isNotEmpty)))
Positioned(
bottom: 2,
right: 2,