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

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

View File

@@ -77,11 +77,16 @@ PODS:
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta8)
- MLKitCommon (~> 14.0)
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- printing (1.0.0):
- Flutter
- PromisesObjC (2.4.1)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
@@ -104,6 +109,8 @@ DEPENDENCIES:
- google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- printing (from `.symlinks/plugins/printing/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -142,6 +149,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
printing:
:path: ".symlinks/plugins/printing/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -167,7 +178,9 @@ SPEC CHECKSUMS:
MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826
MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
printing: 54ff03f28fe9ba3aa93358afb80a8595a071dd07
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class BarcodeScannerScreen extends StatefulWidget {
const BarcodeScannerScreen({super.key});
@override
State<BarcodeScannerScreen> createState() => _BarcodeScannerScreenState();
}
class _BarcodeScannerScreenState extends State<BarcodeScannerScreen> {
final MobileScannerController controller = MobileScannerController();
bool _isScanned = false;
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Scan Barcode')),
body: MobileScanner(
controller: controller,
onDetect: (capture) {
if (_isScanned) return;
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty && barcodes.first.rawValue != null) {
_isScanned = true;
final String code = barcodes.first.rawValue!;
Navigator.pop(context, code);
}
},
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
class PremiumTextField extends StatelessWidget {
final String labelText;
final TextEditingController? controller;
final String? initialValue;
final Widget? prefixIcon;
final Widget? suffixIcon;
final String? suffixText;
final TextInputType? keyboardType;
final TextCapitalization textCapitalization;
final int maxLines;
final bool obscureText;
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final void Function(String?)? onSaved;
final bool darkTheme;
const PremiumTextField({
super.key,
required this.labelText,
this.controller,
this.initialValue,
this.prefixIcon,
this.suffixIcon,
this.suffixText,
this.keyboardType,
this.textCapitalization = TextCapitalization.none,
this.maxLines = 1,
this.obscureText = false,
this.validator,
this.onChanged,
this.onSaved,
this.darkTheme = false,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
initialValue: initialValue,
decoration: InputDecoration(
labelText: labelText,
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
prefixIcon: prefixIcon,
suffixIcon: suffixIcon,
suffixText: suffixText,
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
filled: true,
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
keyboardType: keyboardType,
textCapitalization: textCapitalization,
maxLines: maxLines,
obscureText: obscureText,
onChanged: onChanged,
onSaved: onSaved,
validator: validator,
);
}
}

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
class SmartSearchDropdown<T> extends StatefulWidget {
final List<T> items;
final String Function(T) itemAsString;
final void Function(T?) onChanged;
final T? value;
final String hintText;
final Widget Function(BuildContext, T)? itemBuilder;
final bool Function(T, String)? filterFn;
const SmartSearchDropdown({
super.key,
required this.items,
required this.itemAsString,
required this.onChanged,
this.value,
this.hintText = 'Type to search...',
this.itemBuilder,
this.filterFn,
});
@override
State<SmartSearchDropdown<T>> createState() => _SmartSearchDropdownState<T>();
}
class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
final LayerLink _layerLink = LayerLink();
final FocusNode _focusNode = FocusNode();
final TextEditingController _controller = TextEditingController();
OverlayEntry? _overlayEntry;
bool _showAll = false;
List<T> _filteredItems = [];
@override
void initState() {
super.initState();
_filteredItems = widget.items;
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
}
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_showOverlay();
} else {
_removeOverlay();
// Reset text to selected value if focus lost without selection
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
}
}
});
}
@override
void didUpdateWidget(SmartSearchDropdown<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.items != oldWidget.items) {
_filterItems(_controller.text);
}
if (widget.value != oldWidget.value) {
if (widget.value != null) {
_controller.text = widget.itemAsString(widget.value as T);
} else {
_controller.text = '';
}
}
}
@override
void dispose() {
_focusNode.dispose();
_controller.dispose();
_removeOverlay();
super.dispose();
}
void _filterItems(String query) {
setState(() {
if (query.isEmpty) {
_filteredItems = widget.items;
} else {
_filteredItems = widget.items.where((item) {
if (widget.filterFn != null) {
return widget.filterFn!(item, query);
}
return widget.itemAsString(item).toLowerCase().contains(query.toLowerCase());
}).toList();
}
});
_overlayEntry?.markNeedsBuild();
}
void _showOverlay() {
_removeOverlay();
_showAll = true;
_filteredItems = widget.items;
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
return OverlayEntry(
builder: (context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(0.0, size.height + 5.0),
child: Material(
elevation: 4.0,
borderRadius: BorderRadius.circular(8.0),
color: Theme.of(context).cardColor,
child: StatefulBuilder(
builder: (context, setOverlayState) {
final displayItems = _showAll ? _filteredItems : (_filteredItems.isNotEmpty ? [_filteredItems.first] : <T>[]);
return Container(
constraints: const BoxConstraints(maxHeight: 250),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (displayItems.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No matches found'),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: displayItems.length,
itemBuilder: (context, index) {
final item = displayItems[index];
return InkWell(
onTap: () {
widget.onChanged(item);
_controller.text = widget.itemAsString(item);
_focusNode.unfocus();
},
child: widget.itemBuilder != null
? widget.itemBuilder!(context, item)
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Text(widget.itemAsString(item)),
),
);
},
),
),
if (!_showAll && _controller.text.isEmpty && widget.items.length > 1)
InkWell(
onTap: () {
setOverlayState(() {
_showAll = true;
});
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: Colors.grey.withOpacity(0.2))),
),
child: const Text(
'Show all',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
],
),
);
}
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: widget.hintText,
suffixIcon: const Icon(Icons.arrow_drop_down, color: Colors.grey),
filled: Theme.of(context).inputDecorationTheme.filled ?? true,
fillColor: Theme.of(context).inputDecorationTheme.fillColor ?? Colors.grey[100],
border: Theme.of(context).inputDecorationTheme.border ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: Theme.of(context).inputDecorationTheme.enabledBorder ?? OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: Theme.of(context).inputDecorationTheme.focusedBorder ?? OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
),
onChanged: (val) {
_showAll = true; // when user types, we want to show all matching results
_filterItems(val);
},
),
);
}
}

View File

@@ -237,7 +237,6 @@ class _BudgetScreenState extends ConsumerState<BudgetScreen> {
final transState = ref.watch(transactionProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [
Padding(

View File

@@ -0,0 +1,71 @@
class BusinessFeature {
final int? id;
final int? userId;
final bool inventoryManagement;
final bool salesManagement;
final bool multiLocation;
final String bomReductionStrategy;
final bool stockDeductionOnInvoice;
final String barcodeSource;
final DateTime? createdAt;
BusinessFeature({
this.id,
this.userId,
this.inventoryManagement = false,
this.salesManagement = false,
this.multiLocation = false,
this.bomReductionStrategy = 'COMPONENTS_ONLY',
this.stockDeductionOnInvoice = true,
this.barcodeSource = 'SKU',
this.createdAt,
});
factory BusinessFeature.fromJson(Map<String, dynamic> json) {
return BusinessFeature(
id: json['id'],
userId: json['userId'],
inventoryManagement: json['inventoryManagement'] ?? false,
salesManagement: json['salesManagement'] ?? false,
multiLocation: json['multiLocation'] ?? false,
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
barcodeSource: json['barcodeSource'] ?? 'SKU',
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'inventoryManagement': inventoryManagement,
'salesManagement': salesManagement,
'multiLocation': multiLocation,
'bomReductionStrategy': bomReductionStrategy,
'stockDeductionOnInvoice': stockDeductionOnInvoice,
'barcodeSource': barcodeSource,
};
}
BusinessFeature copyWith({
bool? inventoryManagement,
bool? salesManagement,
bool? multiLocation,
String? bomReductionStrategy,
bool? stockDeductionOnInvoice,
String? barcodeSource,
}) {
return BusinessFeature(
id: id,
userId: userId,
inventoryManagement: inventoryManagement ?? this.inventoryManagement,
salesManagement: salesManagement ?? this.salesManagement,
multiLocation: multiLocation ?? this.multiLocation,
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
barcodeSource: barcodeSource ?? this.barcodeSource,
createdAt: createdAt,
);
}
}

View File

@@ -6,6 +6,13 @@ class BusinessProfile {
final String? taxNumber;
final String? currency;
final bool? taxIncludedInPrice;
final String? address;
final int? stateId;
final String? contactPerson;
final String? contactNumber;
final String? emailId;
final String? panNumber;
final String? gstin;
BusinessProfile({
this.id,
@@ -15,6 +22,13 @@ class BusinessProfile {
this.taxNumber,
this.currency,
this.taxIncludedInPrice,
this.address,
this.stateId,
this.contactPerson,
this.contactNumber,
this.emailId,
this.panNumber,
this.gstin,
});
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
@@ -26,6 +40,13 @@ class BusinessProfile {
taxNumber: json['taxNumber'],
currency: json['currency'],
taxIncludedInPrice: json['taxIncludedInPrice'],
address: json['address'],
stateId: json['stateId'],
contactPerson: json['contactPerson'],
contactNumber: json['contactNumber'],
emailId: json['emailId'],
panNumber: json['panNumber'],
gstin: json['gstin'],
);
}
@@ -38,6 +59,13 @@ class BusinessProfile {
'taxNumber': taxNumber,
'currency': currency,
'taxIncludedInPrice': taxIncludedInPrice,
'address': address,
'stateId': stateId,
'contactPerson': contactPerson,
'contactNumber': contactNumber,
'emailId': emailId,
'panNumber': panNumber,
'gstin': gstin,
};
}
@@ -47,6 +75,13 @@ class BusinessProfile {
String? taxNumber,
String? currency,
bool? taxIncludedInPrice,
String? address,
int? stateId,
String? contactPerson,
String? contactNumber,
String? emailId,
String? panNumber,
String? gstin,
}) {
return BusinessProfile(
id: id,
@@ -56,6 +91,13 @@ class BusinessProfile {
taxNumber: taxNumber ?? this.taxNumber,
currency: currency ?? this.currency,
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
address: address ?? this.address,
stateId: stateId ?? this.stateId,
contactPerson: contactPerson ?? this.contactPerson,
contactNumber: contactNumber ?? this.contactNumber,
emailId: emailId ?? this.emailId,
panNumber: panNumber ?? this.panNumber,
gstin: gstin ?? this.gstin,
);
}
}

View File

@@ -0,0 +1,27 @@
class IndianState {
final int id;
final String name;
final String gstCode;
IndianState({
required this.id,
required this.name,
required this.gstCode,
});
factory IndianState.fromJson(Map<String, dynamic> json) {
return IndianState(
id: json['id'],
name: json['name'],
gstCode: json['gstCode'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'gstCode': gstCode,
};
}
}

View File

@@ -3,38 +3,69 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart';
import '../../../inventory/presentation/product_list_screen.dart';
import '../../../inventory/presentation/quick_adjust_stock_screen.dart';
import '../../../inventory/presentation/uoms_list_screen.dart';
import '../../../sales/presentation/customers_list_screen.dart';
import '../../../sales/presentation/invoices_list_screen.dart';
import '../../providers/business_provider.dart';
import '../widgets/business_profile_form_sheet.dart';
class BusinessHubScreen extends ConsumerWidget {
const BusinessHubScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final businessState = ref.watch(businessProfileProvider);
final profile = businessState.value;
final title = (profile?.businessName != null && profile!.businessName.isNotEmpty)
? profile.businessName
: 'Business Overview';
final subtitle = (profile?.address != null && profile!.address!.isNotEmpty)
? profile.address!
: 'Manage your inventory and stock';
final featureState = ref.watch(businessFeatureProvider).value;
final bool showInventory = featureState?.inventoryManagement ?? false;
final bool showSales = featureState?.salesManagement ?? false;
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
),
child: Row(
children: [
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Business Overview', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const Text('Manage your inventory and stock', style: TextStyle(color: Colors.grey)),
],
InkWell(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const BusinessProfileFormSheet(),
);
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
),
child: Row(
children: [
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
Text(subtitle, style: const TextStyle(color: Colors.grey)),
],
),
),
),
],
],
),
),
),
const SizedBox(height: 24),
@@ -46,56 +77,68 @@ class BusinessHubScreen extends ConsumerWidget {
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.2,
children: [
_buildActionCard(
context,
'Products Catalog',
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
),
_buildActionCard(
context,
'Adjust Stock',
'Add or reduce inventory',
LucideIcons.arrowRightLeft,
Colors.orange,
() {
// TODO: Navigate to Stock Adjustment
},
),
_buildActionCard(
context,
'Sales & Invoices',
'Coming in Phase 2',
LucideIcons.receipt,
Colors.green,
() {},
),
_buildActionCard(
context,
'Customers',
'Coming in Phase 2',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() {},
),
if (showInventory) ...[
_buildActionCard(
context,
'Products Catalog',
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
),
_buildActionCard(
context,
'Adjust Stock',
'Quick Stock Edit',
LucideIcons.arrowRightLeft,
Colors.orange,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuickAdjustStockScreen())),
),
_buildActionCard(
context,
'Units of Measure',
'Manage measurements',
LucideIcons.ruler,
Colors.purple,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const UomsListScreen())),
),
],
if (showSales) ...[
_buildActionCard(
context,
'Sales & Invoices',
'Create invoices',
LucideIcons.receipt,
Colors.green,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())),
),
_buildActionCard(
context,
'Customers',
'Manage clients',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
),
],
],
),
const SizedBox(height: 32),
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Placeholder for low stock items
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
if (showInventory) ...[
const SizedBox(height: 32),
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Placeholder for low stock items
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
),
),
child: const Center(
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
),
),
],
],
),
);

View File

@@ -25,6 +25,13 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
});
}
final feature = ref.read(businessFeatureProvider).value;
if (feature != null) {
setState(() {
_inventoryEnabled = feature.inventoryManagement;
_salesEnabled = feature.salesManagement;
});
}
});
}
@@ -45,17 +52,76 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
title: const Text('Inventory Management'),
subtitle: const Text('Track stock levels, multiple locations, and products'),
value: _inventoryEnabled,
onChanged: (val) => setState(() => _inventoryEnabled = val),
onChanged: (val) {
setState(() => _inventoryEnabled = val);
_saveFeatures();
},
secondary: const Icon(LucideIcons.package),
),
SwitchListTile(
title: const Text('Sales & POS'),
subtitle: const Text('Enable point of sale, invoicing, and receivables'),
value: _salesEnabled,
onChanged: (val) => setState(() => _salesEnabled = val),
onChanged: (val) {
setState(() => _salesEnabled = val);
_saveFeatures();
},
secondary: const Icon(LucideIcons.shoppingCart),
),
const SizedBox(height: 32),
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 16),
Consumer(
builder: (context, ref, child) {
final featureState = ref.watch(businessFeatureProvider);
final currentStrategy = featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY';
final deductParentStock = currentStrategy == 'PARENT_AND_COMPONENTS';
final stockDeductionOnInvoice = featureState.value?.stockDeductionOnInvoice ?? true;
return Column(
children: [
SwitchListTile(
title: const Text('Deduct Parent Stock on BOM Sale'),
subtitle: const Text('If enabled, selling an assembled product reduces both parent and component stock.'),
value: deductParentStock,
secondary: const Icon(LucideIcons.gitMerge),
onChanged: (val) {
if (featureState.value != null) {
final newStrategy = val ? 'PARENT_AND_COMPONENTS' : 'COMPONENTS_ONLY';
final updated = featureState.value!.copyWith(bomReductionStrategy: newStrategy);
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
}
},
),
SwitchListTile(
title: const Text('Stock Deduction on Invoice'),
subtitle: const Text('If enabled, finalizing an invoice automatically deducts product stock. Turn off for a two-step fulfillment process.'),
value: stockDeductionOnInvoice,
secondary: const Icon(LucideIcons.boxes),
onChanged: (val) {
if (featureState.value != null) {
final updated = featureState.value!.copyWith(stockDeductionOnInvoice: val);
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
}
},
),
SwitchListTile(
title: const Text('Use EAN/Barcode for Scanner'),
subtitle: const Text('If disabled, the barcode scanner will search using the SKU field instead.'),
value: (featureState.value?.barcodeSource ?? 'SKU') == 'BARCODE',
secondary: const Icon(LucideIcons.scanLine),
onChanged: (val) {
if (featureState.value != null) {
final updated = featureState.value!.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU');
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
}
},
),
],
);
}
),
const SizedBox(height: 32),
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 16),
SwitchListTile(
@@ -80,4 +146,15 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
ref.read(businessProfileProvider.notifier).updateProfile(updated);
}
}
void _saveFeatures() {
final featureState = ref.read(businessFeatureProvider).value;
if (featureState != null) {
final updated = featureState.copyWith(
inventoryManagement: _inventoryEnabled,
salesManagement: _salesEnabled,
);
ref.read(businessFeatureProvider.notifier).updateFeatures(updated);
}
}
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/business_profile.dart';
import '../../providers/business_provider.dart';
import '../../providers/indian_states_provider.dart';
class BusinessProfileFormSheet extends ConsumerStatefulWidget {
const BusinessProfileFormSheet({super.key});
@override
ConsumerState<BusinessProfileFormSheet> createState() => _BusinessProfileFormSheetState();
}
class _BusinessProfileFormSheetState extends ConsumerState<BusinessProfileFormSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _businessNameController;
late TextEditingController _addressController;
late TextEditingController _contactPersonController;
late TextEditingController _contactNumberController;
late TextEditingController _emailController;
late TextEditingController _panController;
late TextEditingController _gstinController;
int? _selectedStateId;
bool _isLoading = false;
@override
void initState() {
super.initState();
final profile = ref.read(businessProfileProvider).value;
_businessNameController = TextEditingController(text: profile?.businessName ?? '');
_addressController = TextEditingController(text: profile?.address ?? '');
_contactPersonController = TextEditingController(text: profile?.contactPerson ?? '');
_contactNumberController = TextEditingController(text: profile?.contactNumber ?? '');
_emailController = TextEditingController(text: profile?.emailId ?? '');
_panController = TextEditingController(text: profile?.panNumber ?? '');
_gstinController = TextEditingController(text: profile?.gstin ?? '');
_selectedStateId = profile?.stateId;
}
@override
void dispose() {
_businessNameController.dispose();
_addressController.dispose();
_contactPersonController.dispose();
_contactNumberController.dispose();
_emailController.dispose();
_panController.dispose();
_gstinController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final existingProfile = ref.read(businessProfileProvider).value;
final profile = BusinessProfile(
id: existingProfile?.id,
userId: existingProfile?.userId,
businessName: _businessNameController.text.trim(),
address: _addressController.text.trim(),
stateId: _selectedStateId,
contactPerson: _contactPersonController.text.trim(),
contactNumber: _contactNumberController.text.trim(),
emailId: _emailController.text.trim(),
panNumber: _panController.text.trim().toUpperCase(),
gstin: _gstinController.text.trim().toUpperCase(),
);
await ref.read(businessProfileProvider.notifier).updateProfile(profile);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Business profile updated successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final statesState = ref.watch(indianStatesProvider);
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Business Profile', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(height: 1),
Expanded(
child: Form(
key: _formKey,
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _businessNameController,
decoration: InputDecoration(labelText: 'Company Name', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
validator: (v) => v == null || v.isEmpty ? 'Company name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _addressController,
decoration: InputDecoration(labelText: 'Address', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
maxLines: 2,
),
const SizedBox(height: 16),
statesState.when(
data: (states) {
return DropdownButtonFormField<int>(
value: _selectedStateId,
decoration: InputDecoration(labelText: 'State', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
items: states.map((s) {
return DropdownMenuItem(
value: s.id,
child: Text('${s.name} (${s.gstCode})'),
);
}).toList(),
onChanged: (val) {
setState(() {
_selectedStateId = val;
});
},
);
},
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
),
error: (e, stack) => Text('Failed to load states: $e', style: const TextStyle(color: Colors.red)),
),
const SizedBox(height: 16),
TextFormField(
controller: _contactPersonController,
decoration: InputDecoration(labelText: 'Contact Person', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
),
const SizedBox(height: 16),
TextFormField(
controller: _contactNumberController,
decoration: InputDecoration(labelText: 'Contact Number', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email ID', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextFormField(
controller: _panController,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(labelText: 'PAN Number', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
validator: (v) {
if (v != null && v.isNotEmpty) {
final regex = RegExp(r'^[A-Z]{5}[0-9]{4}[A-Z]{1}$');
if (!regex.hasMatch(v.toUpperCase())) {
return 'Invalid PAN format';
}
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _gstinController,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(labelText: 'GSTIN', border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]),
validator: (v) {
if (v != null && v.isNotEmpty) {
final regex = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$');
if (!regex.hasMatch(v.toUpperCase())) {
return 'Invalid GSTIN format';
}
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Save Business Profile', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 32),
],
),
),
),
],
);
},
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/business_profile.dart';
import '../domain/business_feature.dart';
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
@override
@@ -37,3 +38,42 @@ class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
return BusinessProfileNotifier();
});
class BusinessFeatureNotifier extends AsyncNotifier<BusinessFeature?> {
@override
FutureOr<BusinessFeature?> build() async {
return _fetchFeatures();
}
Future<BusinessFeature?> _fetchFeatures() async {
try {
final response = await DioClient().dio.get('/business/features');
if (response.statusCode == 200) {
return BusinessFeature.fromJson(response.data);
}
} catch (e) {
// Return default if error
}
return BusinessFeature();
}
Future<void> updateFeatures(BusinessFeature feature) async {
state = const AsyncValue.loading();
try {
final response = await DioClient().dio.post(
'/business/features',
data: feature.toJson(),
);
if (response.statusCode == 200) {
state = AsyncValue.data(BusinessFeature.fromJson(response.data));
}
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
}
final businessFeatureProvider = AsyncNotifierProvider<BusinessFeatureNotifier, BusinessFeature?>(() {
return BusinessFeatureNotifier();
});

View File

@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/indian_state.dart';
class IndianStatesNotifier extends AsyncNotifier<List<IndianState>> {
@override
Future<List<IndianState>> build() async {
return _fetchStates();
}
Future<List<IndianState>> _fetchStates() async {
final response = await DioClient().dio.get('/master/states').timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('Connection timed out'),
);
if (response.data == null || response.data.toString().isEmpty) {
return [];
}
return (response.data as List).map((e) => IndianState.fromJson(e)).toList();
}
}
final indianStatesProvider = AsyncNotifierProvider<IndianStatesNotifier, List<IndianState>>(() {
return IndianStatesNotifier();
});

View File

@@ -42,9 +42,18 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
void _showCreateWalletDialog() {
final ctrl = TextEditingController();
final amtCtrl = TextEditingController();
final creditLimitCtrl = TextEditingController();
final fixedAmountCtrl = TextEditingController();
final cycleDateCtrl = TextEditingController();
DateTime openingDate = DateTime.now();
String selectedNature = 'CASH';
String? selectedSubNature;
String? selectedPaymentCycle;
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
@@ -117,14 +126,121 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedNature = val);
if (val != null) setStateDialog(() {
selectedNature = val;
if (val != 'PAYABLES') {
selectedSubNature = null;
selectedPaymentCycle = null;
creditLimitCtrl.clear();
fixedAmountCtrl.clear();
cycleDateCtrl.clear();
} else {
selectedSubNature = 'CREDIT_CARD';
selectedPaymentCycle = 'MONTHLY';
}
});
},
),
const SizedBox(height: 16),
if (selectedNature == 'PAYABLES') ...[
DropdownButtonFormField<String>(
value: selectedSubNature,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Sub-Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedSubNature = val);
},
),
const SizedBox(height: 16),
if (selectedSubNature == 'CREDIT_CARD' || selectedSubNature == 'OD_LIMIT') ...[
TextField(
controller: creditLimitCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Credit Limit (Optional)',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
],
if (selectedSubNature == 'LOAN_EMI' || selectedSubNature == 'POLICY_PREMIUM') ...[
TextField(
controller: fixedAmountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Fixed Amount Due (Optional)',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
flex: 2,
child: DropdownButtonFormField<String>(
value: selectedPaymentCycle,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Cycle',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedPaymentCycle = val);
},
),
),
const SizedBox(width: 8),
Expanded(
flex: 1,
child: TextField(
controller: cycleDateCtrl,
keyboardType: TextInputType.number,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
labelText: 'Date (1-31)',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
),
],
),
const SizedBox(height: 16),
],
TextField(
controller: amtCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
@@ -189,20 +305,30 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
onPressed: () async {
if (ctrl.text.isNotEmpty) {
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
final double? cl = double.tryParse(creditLimitCtrl.text);
final double? fa = double.tryParse(fixedAmountCtrl.text);
final int? cd = int.tryParse(cycleDateCtrl.text);
final newWallet = await ref.read(walletProvider.notifier).createWallet(
name: ctrl.text,
nature: selectedNature,
initialBalance: 0.0,
subNature: selectedNature == 'PAYABLES' ? selectedSubNature : null,
creditLimit: cl,
fixedAmount: fa,
paymentCycle: selectedNature == 'PAYABLES' ? selectedPaymentCycle : null,
cycleDate: cd,
);
if (amt > 0) {
if (amt != 0) {
final tx = Transaction(
id: 0,
type: 'INCOME',
amount: amt,
type: amt > 0 ? 'INCOME' : 'EXPENSE',
amount: amt.abs(),
date: openingDate,
description: 'Opening Balance',
toWalletId: newWallet.id,
fromWalletId: amt < 0 ? newWallet.id : null,
toWalletId: amt > 0 ? newWallet.id : null,
);
await ref.read(transactionProvider.notifier).addTransaction(tx);
}
@@ -335,7 +461,6 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
final walletsState = ref.watch(walletProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
bottom: false,
child: Column(
@@ -472,7 +597,16 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
onPressed: () {
final editCtrl = TextEditingController(text: w.name);
String editNature = w.nature ?? 'CASH';
String? editSubNature = w.subNature;
final editCreditLimitCtrl = TextEditingController(text: w.creditLimit?.toString() ?? '');
final editFixedAmountCtrl = TextEditingController(text: w.fixedAmount?.toString() ?? '');
final editCycleDateCtrl = TextEditingController(text: w.cycleDate?.toString() ?? '');
String? editPaymentCycle = w.paymentCycle;
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
final subNatures = ['CREDIT_CARD', 'OD_LIMIT', 'LOAN_EMI', 'POLICY_PREMIUM', 'OTHER'];
final paymentCycles = ['DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
@@ -480,9 +614,11 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -510,10 +646,107 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editNature = val);
if (val != null) setStateDialog(() {
editNature = val;
if (val != 'PAYABLES') {
editSubNature = null;
editPaymentCycle = null;
editCreditLimitCtrl.clear();
editFixedAmountCtrl.clear();
editCycleDateCtrl.clear();
} else if (editSubNature == null) {
editSubNature = 'CREDIT_CARD';
editPaymentCycle = 'MONTHLY';
}
});
},
),
const SizedBox(height: 24),
const SizedBox(height: 16),
if (editNature == 'PAYABLES') ...[
DropdownButtonFormField<String>(
value: editSubNature,
decoration: InputDecoration(
labelText: 'Sub-Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
items: subNatures.map((n) => DropdownMenuItem(value: n, child: Text(n.replaceAll('_', ' ')))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editSubNature = val);
},
),
const SizedBox(height: 16),
if (editSubNature == 'CREDIT_CARD' || editSubNature == 'OD_LIMIT') ...[
TextField(
controller: editCreditLimitCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
decoration: InputDecoration(
hintText: 'Credit Limit (Optional)',
prefixText: 'Rs. ',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
const SizedBox(height: 16),
],
if (editSubNature == 'LOAN_EMI' || editSubNature == 'POLICY_PREMIUM') ...[
TextField(
controller: editFixedAmountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
decoration: InputDecoration(
hintText: 'Fixed Amount Due (Optional)',
prefixText: 'Rs. ',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
flex: 2,
child: DropdownButtonFormField<String>(
value: editPaymentCycle,
decoration: InputDecoration(
labelText: 'Cycle',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
items: paymentCycles.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editPaymentCycle = val);
},
),
),
const SizedBox(width: 8),
Expanded(
flex: 1,
child: TextField(
controller: editCycleDateCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Date (1-31)',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
),
],
),
const SizedBox(height: 16),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -525,11 +758,20 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
ElevatedButton(
onPressed: () async {
if (editCtrl.text.isNotEmpty) {
final double? cl = double.tryParse(editCreditLimitCtrl.text);
final double? fa = double.tryParse(editFixedAmountCtrl.text);
final int? cd = int.tryParse(editCycleDateCtrl.text);
try {
await ref.read(walletProvider.notifier).editWallet(
w.id,
name: editCtrl.text.trim(),
nature: editNature,
subNature: editNature == 'PAYABLES' ? editSubNature : null,
creditLimit: cl,
fixedAmount: fa,
paymentCycle: editNature == 'PAYABLES' ? editPaymentCycle : null,
cycleDate: cd,
);
if (context.mounted) {
Navigator.pop(ctx);
@@ -549,6 +791,7 @@ class _AccountsScreenState extends ConsumerState<AccountsScreen> {
),
],
),
),
),
),
),

View File

@@ -12,7 +12,13 @@ import '../../transactions/data/models.dart';
import '../../budget/presentation/budget_screen.dart';
import '../providers/insight_provider.dart';
import '../../sales/providers/invoices_provider.dart';
import '../../sales/providers/customers_provider.dart';
import '../../sales/domain/invoice.dart';
import '../../sales/presentation/customers_list_screen.dart';
import 'widgets/swipeable_account_card.dart';
import 'widgets/budget_status_card.dart';
import 'widgets/upcoming_dues_widget.dart';
import 'widgets/statistics_tab.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../core/theme/nature_colors.dart';
@@ -57,6 +63,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
ref.invalidate(categoryProvider);
ref.invalidate(budgetProvider);
ref.invalidate(invitationProvider);
ref.invalidate(invoicesProvider);
ref.invalidate(customersProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
@@ -139,6 +147,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final categoriesState = ref.watch(categoryProvider);
final insight = ref.watch(insightProvider);
final walletsState = ref.watch(walletProvider);
final invoicesState = ref.watch(invoicesProvider);
final customersState = ref.watch(customersProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final allTransactions = transState.value ?? [];
@@ -147,10 +157,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final endDate = range.end;
String title;
if (_currentIndex == 0) title = 'Dashboard';
else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
if (_currentIndex == 0) {
title = 'Dashboard';
} else if (isBusinessMode) {
if (_currentIndex == 1) title = 'Business Hub';
else if (_currentIndex == 2) title = 'Statistics';
else if (_currentIndex == 3) title = 'My Accounts';
else title = 'Budgets';
} else {
if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
}
return Scaffold(
appBar: AppBar(
@@ -308,6 +326,68 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
}
}
// Process invoices to get total pending
double totalPendingInvoices = 0;
Map<int, double> pendingByCustomer = {};
if (invoicesState.hasValue && customersState.hasValue) {
final invoices = invoicesState.value!;
for (var inv in invoices) {
if (inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.customerId != null) {
double paidAmount = inv.amountPaid ?? 0;
double pendingAmount = inv.totalAmount - paidAmount;
if (pendingAmount > 0) {
pendingByCustomer[inv.customerId!] = (pendingByCustomer[inv.customerId!] ?? 0) + pendingAmount;
totalPendingInvoices += pendingAmount;
}
}
}
}
receivablesBalance += totalPendingInvoices;
// Build Payables Carousel Items
List<CarouselItemData> payablesItems = [];
if (walletsState.hasValue) {
final payableWallets = walletsState.value!.where((w) => w.nature == 'PAYABLES' || w.nature == 'LOAN').toList();
for (var w in payableWallets) {
if (w.balance > 0) {
payablesItems.add(CarouselItemData(
title: 'To: ${w.name}',
amount: w.balance,
color: NatureColors.getColor('PAYABLES'),
icon: LucideIcons.userMinus,
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
}
));
}
}
}
// Build Receivables Carousel Items
List<CarouselItemData> receivablesItems = [];
if (invoicesState.hasValue && customersState.hasValue) {
final customers = customersState.value!;
pendingByCustomer.forEach((custId, amount) {
if (amount > 0) {
final customerName = customers.where((c) => c.id == custId).firstOrNull?.name ?? 'Unknown Customer';
receivablesItems.add(CarouselItemData(
title: 'From: $customerName',
amount: amount,
color: NatureColors.getColor('RECEIVABLES'),
icon: LucideIcons.userPlus,
onTap: () {
// Navigate to customer details or invoices in the future
}
));
}
});
}
return IndexedStack(
index: _currentIndex,
children: [
@@ -352,7 +432,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
const BudgetStatusCard(),
const SizedBox(height: 24),
// Summary Cards Grid
GridView.count(
crossAxisCount: 2,
@@ -362,14 +441,34 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'),
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'),
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'),
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'),
_buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'),
_buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'),
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'CASH')))),
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'EXPENSE')))),
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'SAVINGS')))),
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'INVESTMENTS')))),
_buildSummaryCard(context, 'Total Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AccountsScreen(initialFilterNature: 'PAYABLES')))),
_buildSummaryCard(context, 'Total Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen()));
}),
],
),
const SizedBox(height: 24),
const UpcomingDuesWidget(),
if (payablesItems.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Upcoming Payables', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
SwipeableAccountCard(items: payablesItems),
],
if (receivablesItems.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Upcoming Receivables', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
SwipeableAccountCard(items: receivablesItems),
],
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -515,8 +614,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
// ---------------- STATS/BUSINESS TAB ----------------
isBusinessMode ? const BusinessHubScreen() : StatisticsTab(
// ---------------- BUSINESS TAB ----------------
if (isBusinessMode) const BusinessHubScreen(),
// ---------------- STATS TAB ----------------
StatisticsTab(
transactions: transactions,
wallets: safeWallets,
categories: categoriesState.value ?? [],
@@ -541,14 +643,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
},
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex,
currentIndex: isBusinessMode
? (_currentIndex >= 3 ? _currentIndex + 1 : _currentIndex)
: (_currentIndex >= 2 ? _currentIndex + 1 : _currentIndex),
type: BottomNavigationBarType.fixed,
onTap: (index) {
if (index == 2) {
final addIndex = isBusinessMode ? 3 : 2;
if (index == addIndex) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > 2 ? index - 1 : index;
_currentIndex = index > addIndex ? index - 1 : index;
});
}
},
@@ -558,9 +663,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
showUnselectedLabels: true,
items: [
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart),
label: isBusinessMode ? 'Business' : 'Stats'),
if (isBusinessMode)
const BottomNavigationBarItem(icon: Icon(LucideIcons.briefcase), label: 'Business'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
const BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
@@ -569,16 +674,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, [String? nature]) {
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, {VoidCallback? onTap}) {
return GestureDetector(
onTap: nature != null ? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AccountsScreen(initialFilterNature: nature),
),
);
} : null,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class CarouselItemData {
final String title;
final String subtitle;
final double amount;
final Color color;
final IconData icon;
final VoidCallback? onTap;
CarouselItemData({
required this.title,
this.subtitle = '',
required this.amount,
required this.color,
required this.icon,
this.onTap,
});
}
class SwipeableAccountCard extends StatefulWidget {
final List<CarouselItemData> items;
const SwipeableAccountCard({
super.key,
required this.items,
});
@override
State<SwipeableAccountCard> createState() => _SwipeableAccountCardState();
}
class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
int _currentPage = 0;
late PageController _pageController;
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: 0);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.items.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
SizedBox(
height: 85, // adjusted height for the row-based card
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemCount: widget.items.length,
itemBuilder: (context, index) {
final item = widget.items[index];
return GestureDetector(
onTap: item.onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: item.color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
item.icon,
color: item.color,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.subtitle,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
]
],
),
),
Text(
'Rs. ${item.amount.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.black87),
),
],
),
),
);
},
),
),
if (widget.items.length > 1) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.items.length,
(index) => Container(
margin: const EdgeInsets.symmetric(horizontal: 4.0),
width: 8.0,
height: 8.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _currentPage == index
? widget.items[index].color
: Colors.grey.withOpacity(0.3),
),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../transactions/providers/providers.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
class UpcomingDuesWidget extends ConsumerWidget {
const UpcomingDuesWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final walletsState = ref.watch(walletProvider);
if (!walletsState.hasValue || walletsState.value == null) {
return const SizedBox.shrink();
}
final wallets = walletsState.value!;
// Filter for payables that have a due date/amount
final payables = wallets.where((w) => w.nature == 'PAYABLES').toList();
if (payables.isEmpty) {
return const SizedBox.shrink();
}
final now = DateTime.now();
// Calculate dues
final List<Map<String, dynamic>> dues = [];
for (var w in payables) {
double dueAmount = 0;
DateTime? dueDate;
String dueLabel = 'Upcoming Due';
if (w.subNature == 'CREDIT_CARD' || w.subNature == 'OD_LIMIT') {
// Balance is negative if we owe money
if (w.balance < 0) {
dueAmount = w.balance.abs();
}
} else if (w.subNature == 'LOAN_EMI' || w.subNature == 'POLICY_PREMIUM') {
dueAmount = w.fixedAmount ?? 0;
}
if (dueAmount > 0 && w.cycleDate != null) {
int year = now.year;
int month = now.month;
// Find next due date based on cycle
if (w.paymentCycle == 'MONTHLY' || w.paymentCycle == null) {
if (now.day > w.cycleDate!) {
// Due date has passed for this month, next is next month
month++;
if (month > 12) {
month = 1;
year++;
}
}
} else if (w.paymentCycle == 'YEARLY') {
// Assume cycleDate is day of current month, this is simplistic
if (now.day > w.cycleDate!) {
year++;
}
}
// Handle end of month issues (e.g. Feb 30th)
int maxDays = DateTime(year, month + 1, 0).day;
int day = w.cycleDate! > maxDays ? maxDays : w.cycleDate!;
dueDate = DateTime(year, month, day);
int daysLeft = dueDate.difference(DateTime(now.year, now.month, now.day)).inDays;
if (daysLeft == 0) {
dueLabel = 'Due Today';
} else if (daysLeft == 1) {
dueLabel = 'Due Tomorrow';
} else {
dueLabel = 'Due in $daysLeft days';
}
dues.add({
'wallet': w,
'amount': dueAmount,
'dueDate': dueDate,
'label': dueLabel,
'daysLeft': daysLeft,
});
}
}
if (dues.isEmpty) {
return const SizedBox.shrink();
}
// Sort by nearest due date
dues.sort((a, b) => (a['daysLeft'] as int).compareTo(b['daysLeft'] as int));
// Only show top 3 dues
final displayDues = dues.take(3).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(LucideIcons.calendarClock, color: Color(0xFF6C63FF), size: 20),
const SizedBox(width: 8),
Text('Upcoming Dues', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 12),
...displayDues.map((due) {
final w = due['wallet'] as Wallet;
final amount = due['amount'] as double;
final dueDate = due['dueDate'] as DateTime;
final label = due['label'] as String;
final daysLeft = due['daysLeft'] as int;
final isUrgent = daysLeft <= 3;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isUrgent ? Colors.red.shade50 : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isUrgent ? Colors.red.shade200 : Colors.grey.shade200),
boxShadow: [
if (!isUrgent) BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 8, offset: const Offset(0, 2))
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (isUrgent ? Colors.red : NatureColors.getColor('PAYABLES')).withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
w.subNature == 'CREDIT_CARD' ? LucideIcons.creditCard :
(w.subNature == 'LOAN_EMI' ? LucideIcons.home : LucideIcons.fileText),
color: isUrgent ? Colors.red : NatureColors.getColor('PAYABLES'),
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)),
const SizedBox(height: 4),
Text(
'$label${DateFormat('MMM dd').format(dueDate)}',
style: TextStyle(
color: isUrgent ? Colors.red.shade700 : Colors.grey.shade600,
fontSize: 12,
fontWeight: isUrgent ? FontWeight.bold : FontWeight.normal
),
),
],
),
),
Text(
'Rs. ${amount.toStringAsFixed(0)}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isUrgent ? Colors.red.shade700 : Colors.black87
),
),
],
),
);
}),
const SizedBox(height: 12),
],
);
}
}

View File

@@ -0,0 +1,39 @@
class CategoryRateHistory {
final int id;
final int categoryId;
final double rate;
final DateTime date;
final DateTime createdAt;
final DateTime updatedAt;
CategoryRateHistory({
required this.id,
required this.categoryId,
required this.rate,
required this.date,
required this.createdAt,
required this.updatedAt,
});
factory CategoryRateHistory.fromJson(Map<String, dynamic> json) {
return CategoryRateHistory(
id: json['id'],
categoryId: json['categoryId'] ?? json['category_id'],
rate: (json['rate'] as num).toDouble(),
date: DateTime.parse(json['date']),
createdAt: DateTime.parse(json['createdAt'] ?? json['created_at']),
updatedAt: DateTime.parse(json['updatedAt'] ?? json['updated_at']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryId': categoryId,
'rate': rate,
'date': "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}",
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
}

View File

@@ -25,6 +25,7 @@ class Product {
final bool trackInventory;
final bool isActive;
final List<int> imageIds;
final double? currentStock;
Product({
this.id,
@@ -53,38 +54,40 @@ class Product {
this.trackInventory = true,
this.isActive = true,
this.imageIds = const [],
this.currentStock = 0.0,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
userId: json['userId'],
categoryId: json['categoryId'],
uomId: json['uomId'],
userId: json['userId'] ?? json['user_id'],
categoryId: json['categoryId'] ?? json['category_id'],
uomId: json['uomId'] ?? json['uom_id'],
name: json['name'],
sku: json['sku'],
barcode: json['barcode'],
description: json['description'],
purchasePrice: (json['purchasePrice'] as num?)?.toDouble(),
sellingPrice: (json['sellingPrice'] as num?)?.toDouble(),
minStock: (json['minStock'] as num?)?.toDouble(),
reorderLevel: (json['reorderLevel'] as num?)?.toDouble(),
gstRate: (json['gstRate'] as num?)?.toDouble(),
purchasePrice: (json['purchasePrice'] ?? json['purchase_price'] as num?)?.toDouble(),
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(),
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble(),
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble(),
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
dimensions: json['dimensions'],
weight: (json['weight'] as num?)?.toDouble(),
color: json['color'],
size: json['size'],
priceCalcRule: json['priceCalcRule'] ?? 'MANUAL',
autoCalculatePrice: json['autoCalculatePrice'] ?? false,
purityFactor: (json['purityFactor'] as num?)?.toDouble() ?? 1.0,
makingCharges: (json['makingCharges'] as num?)?.toDouble() ?? 0.0,
makingChargesType: json['makingChargesType'] ?? 'FLAT',
wastagePercentage: (json['wastagePercentage'] as num?)?.toDouble() ?? 0.0,
trackInventory: json['trackInventory'] ?? true,
isActive: json['isActive'] ?? true,
priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? 1.0,
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0,
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0,
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true,
isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null
? (json['images'] as List).map((i) => i['id'] as int).toList()
: [],
currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0,
);
}

View File

@@ -0,0 +1,39 @@
class ProductBom {
final int? id;
final int parentProductId;
final int componentProductId;
final double quantity;
final DateTime? createdAt;
// Optional field to store the actual component product details fetched from the API
final Map<String, dynamic>? componentProduct;
ProductBom({
this.id,
required this.parentProductId,
required this.componentProductId,
required this.quantity,
this.createdAt,
this.componentProduct,
});
factory ProductBom.fromJson(Map<String, dynamic> json) {
return ProductBom(
id: json['id'],
parentProductId: json['parentProductId'],
componentProductId: json['componentProductId'],
quantity: (json['quantity'] as num).toDouble(),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
componentProduct: json['componentProduct'],
);
}
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
'parentProductId': parentProductId,
'componentProductId': componentProductId,
'quantity': quantity,
};
}
}

View File

@@ -0,0 +1,85 @@
class StockMovementItem {
final int? id;
final int? movementId;
final int? productId;
final double quantity;
final double? unitPrice;
StockMovementItem({
this.id,
this.movementId,
this.productId,
required this.quantity,
this.unitPrice,
});
factory StockMovementItem.fromJson(Map<String, dynamic> json) {
return StockMovementItem(
id: json['id'],
movementId: json['movementId'],
productId: json['productId'],
quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unitPrice'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'movementId': movementId,
'productId': productId,
'quantity': quantity,
'unitPrice': unitPrice,
};
}
}
class StockMovement {
final int? id;
final int? userId;
final int? locationId;
final String type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN
final int? referenceTransactionId;
final String? notes;
final DateTime? createdAt;
final List<StockMovementItem>? items;
StockMovement({
this.id,
this.userId,
this.locationId,
required this.type,
this.referenceTransactionId,
this.notes,
this.createdAt,
this.items,
});
factory StockMovement.fromJson(Map<String, dynamic> json) {
return StockMovement(
id: json['id'],
userId: json['userId'],
locationId: json['locationId'],
type: json['type'],
referenceTransactionId: json['referenceTransactionId'],
notes: json['notes'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
items: json['items'] != null
? (json['items'] as List).map((i) => StockMovementItem.fromJson(i)).toList()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'locationId': locationId,
'type': type,
'referenceTransactionId': referenceTransactionId,
'notes': notes,
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
if (items != null) 'items': items!.map((i) => i.toJson()).toList(),
};
}
}

View File

@@ -0,0 +1,39 @@
class UnitOfMeasure {
final int? id;
final String name;
final String? abbreviation;
UnitOfMeasure({
this.id,
required this.name,
this.abbreviation,
});
factory UnitOfMeasure.fromJson(Map<String, dynamic> json) {
return UnitOfMeasure(
id: json['id'],
name: json['name'],
abbreviation: json['abbreviation'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'abbreviation': abbreviation,
};
}
UnitOfMeasure copyWith({
int? id,
String? name,
String? abbreviation,
}) {
return UnitOfMeasure(
id: id ?? this.id,
name: name ?? this.name,
abbreviation: abbreviation ?? this.abbreviation,
);
}
}

View File

@@ -1,14 +1,19 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
import '../../../core/network/dio_client.dart';
import '../../../../core/widgets/smart_search_dropdown.dart';
import '../domain/product.dart';
import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart';
import '../providers/uoms_provider.dart';
import '../domain/uom.dart';
import '../../business/providers/business_provider.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
class AddProductScreen extends ConsumerStatefulWidget {
final Product? product;
@@ -28,6 +33,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
String _name = '';
String _sku = '';
ProductCategory? _selectedCategory;
int? _uomId;
// Properties
String _color = '';
@@ -50,15 +56,22 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
// Media
final List<XFile> _images = [];
final List<int> _existingImageIds = [];
bool _isSaving = false;
String? _token;
@override
void initState() {
super.initState();
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
if (mounted) setState(() => _token = val);
});
if (widget.product != null) {
final p = widget.product!;
_name = p.name;
_sku = p.sku ?? '';
_uomId = p.uomId;
_color = p.color ?? '';
_size = p.size ?? '';
_dimensions = p.dimensions ?? '';
@@ -73,6 +86,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_makingChargesType = p.makingChargesType ?? 'FLAT';
_wastagePercentage = p.wastagePercentage ?? 0.0;
if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final cats = ref.read(productCategoriesProvider).value ?? [];
if (cats.isNotEmpty) {
@@ -105,7 +122,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
Future<void> _pickImages() async {
if (_images.length >= 4) {
if ((_images.length + _existingImageIds.length) >= 4) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
return;
}
@@ -113,7 +130,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final List<XFile> picked = await picker.pickMultiImage();
if (picked.isNotEmpty) {
setState(() {
_images.addAll(picked.take(4 - _images.length));
_images.addAll(picked.take(4 - (_images.length + _existingImageIds.length)));
});
}
}
@@ -124,6 +141,30 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
});
}
void _previewImage(int index) {
List<ImageProvider> allImages = [];
for (final imageId in _existingImageIds) {
if (_token != null) {
allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'}));
} else {
allImages.add(const AssetImage('assets/images/placeholder.png'));
}
}
for (final file in _images) {
allImages.add(FileImage(File(file.path)));
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: index,
),
),
);
}
void _showAddCategoryDialog() {
String newCatName = '';
bool newCatCommodity = false;
@@ -156,10 +197,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
onChanged: (val) => setDialogState(() => newCatCommodity = val),
),
if (newCatCommodity) ...[
_buildPremiumDropdown(
label: 'Calculation Method',
// Replace DropdownButtonFormField with SmartSearchDropdown
SmartSearchDropdown<String>(
hintText: 'Calculation Method',
value: newCalcMethod,
items: ['UNIT', 'WEIGHT', 'VOLUME'],
items: const ['UNIT', 'WEIGHT', 'VOLUME'],
itemAsString: (val) => val,
onChanged: (val) => setDialogState(() {
newCalcMethod = val!;
if (val == 'WEIGHT') newBaseUnit = 'gm';
@@ -224,15 +267,18 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
setState(() => _isSaving = true);
try {
final product = Product(
id: widget.product?.id,
name: _name,
sku: _sku.isNotEmpty ? _sku : null,
categoryId: _selectedCategory?.id,
uomId: _uomId,
color: _color.isNotEmpty ? _color : null,
size: _size.isNotEmpty ? _size : null,
dimensions: _dimensions.isNotEmpty ? _dimensions : null,
purchasePrice: _purchasePrice,
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
gstRate: _gstRate,
weight: _weight,
color: _color,
size: _size,
dimensions: _dimensions,
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
autoCalculatePrice: _autoCalculatePrice,
purityFactor: _purityFactor,
@@ -258,9 +304,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
double _calculateLivePrice() {
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
double rate = _selectedCategory!.dailyRate ?? 0;
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
double baseVal = (_weight > 0) ? _weight : 1.0;
// Base Material Cost = (Weight + Wastage) * Rate * Purity
// Base Material Cost = (Base Quantity + Wastage) * Rate * Purity
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
double materialCost = materialWeight * rate * _purityFactor;
@@ -431,11 +477,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
),
)
else
_buildPremiumDropdown<ProductCategory>(
label: 'Select Category*',
// Replace DropdownButtonFormField with SmartSearchDropdown
SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemLabel: (c) {
itemAsString: (c) {
String displayName = c.name;
if (c.parentCategoryId != null) {
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
@@ -460,6 +507,31 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
);
},
),
const SizedBox(height: 32),
const Text('Unit of Measure', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Consumer(
builder: (context, ref, child) {
final uomsState = ref.watch(uomsProvider);
return uomsState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading UOMs: $err'),
data: (uoms) {
return SmartSearchDropdown<int>(
hintText: 'Select Unit of Measure (Optional)',
value: _uomId,
items: uoms.map((u) => u.id!).toList(),
itemAsString: (id) {
final uom = uoms.firstWhere((u) => u.id == id);
return '${uom.name} (${uom.abbreviation})';
},
onChanged: (val) => setState(() => _uomId = val),
);
},
);
},
),
],
),
);
@@ -477,11 +549,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
const SizedBox(height: 32),
_buildPremiumTextField(
label: 'Weight',
label: _selectedCategory?.isCommodity == true ? 'Volume / Weight' : 'Weight',
initialValue: _weight == 0 ? '' : _weight.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
suffixText: _selectedCategory?.baseUnit ?? 'unit',
suffixText: _getUomAbbreviation() ?? _selectedCategory?.baseUnit ?? 'unit',
),
const SizedBox(height: 20),
_buildPremiumTextField(
@@ -567,7 +639,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Row(
children: [
Expanded(
flex: 2,
flex: 1,
child: _buildPremiumTextField(
label: 'Making Charges',
initialValue: _makingCharges.toString(),
@@ -583,7 +655,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
label: 'Type',
value: _makingChargesType,
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory?.baseUnit ?? "Unit"}' : t == 'PERCENTAGE' ? 'Percentage' : 'Flat',
onChanged: (val) => setState(() => _makingChargesType = val!),
darkTheme: true,
),
@@ -666,7 +738,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
if (_images.isNotEmpty)
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -676,46 +748,42 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
mainAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: _images.length,
itemCount: _images.length + _existingImageIds.length,
itemBuilder: (context, index) {
return Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
color: Colors.grey[200],
child: Image.file(
File(_images[index].path),
fit: BoxFit.cover,
),
),
),
),
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => _removeImage(index),
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
padding: const EdgeInsets.all(6),
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
),
),
)
],
);
if (index < _existingImageIds.length) {
final imageId = _existingImageIds[index];
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
return _buildImageThumbnail(
isNetwork: true,
url: imageUrl,
onTap: () => _previewImage(index),
onDelete: () async {
try {
setState(() => _isSaving = true);
await DioClient().dio.delete('/inventory/products/images/$imageId');
setState(() => _existingImageIds.removeAt(index));
// Update product list in background
ref.read(productsProvider.notifier).refresh();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete image: $e')));
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
);
} else {
final localIndex = index - _existingImageIds.length;
return _buildImageThumbnail(
isNetwork: false,
file: File(_images[localIndex].path),
onTap: () => _previewImage(index),
onDelete: () => _removeImage(localIndex)
);
}
},
),
const SizedBox(height: 24),
if (_images.length < 4)
if ((_images.length + _existingImageIds.length) < 4)
GestureDetector(
onTap: _pickImages,
child: Container(
@@ -731,7 +799,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text('${4 - _images.length} slots remaining', style: const TextStyle(color: Colors.grey)),
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey)),
],
),
),
@@ -741,6 +809,55 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
);
}
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
return Stack(
fit: StackFit.expand,
children: [
GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
color: Colors.grey[200],
child: isNetwork
? (_token == null
? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network(
url!,
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
))
: Image.file(
file!,
fit: BoxFit.cover,
),
),
),
),
),
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: onDelete,
child: Container(
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
padding: const EdgeInsets.all(6),
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
),
),
)
],
);
}
// ==== WIDGET HELPERS ====
Widget _buildPremiumTextField({
@@ -787,26 +904,50 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
required void Function(T?) onChanged,
bool darkTheme = false,
}) {
return DropdownButtonFormField<T>(
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
filled: true,
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
),
return Theme(
data: darkTheme ? Theme.of(context).copyWith(
textTheme: Theme.of(context).textTheme.apply(bodyColor: Colors.white, displayColor: Colors.white),
inputDecorationTheme: InputDecorationTheme(
labelStyle: const TextStyle(color: Colors.white70),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Colors.white, width: 2)
),
)
) : Theme.of(context).copyWith(
inputDecorationTheme: InputDecorationTheme(
labelStyle: TextStyle(color: Colors.grey[600]),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
),
)
),
child: SmartSearchDropdown<T>(
hintText: label,
value: value,
items: items,
itemAsString: (e) => itemLabel != null ? itemLabel(e) : e.toString(),
onChanged: onChanged,
),
dropdownColor: darkTheme ? Colors.blue.shade900 : null,
style: TextStyle(color: darkTheme ? Colors.white : Colors.black87, fontSize: 16),
value: value,
items: items.map((e) => DropdownMenuItem(
value: e,
child: Text(itemLabel != null ? itemLabel(e) : e.toString()),
)).toList(),
onChanged: onChanged,
);
}
String? _getUomAbbreviation() {
if (_uomId == null) return null;
final uomsState = ref.read(uomsProvider).value;
if (uomsState == null) return null;
try {
final uom = uomsState.firstWhere((u) => u.id == _uomId);
return uom.abbreviation;
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/core/widgets/smart_search_dropdown.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/domain/product_bom.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
class BomTab extends ConsumerStatefulWidget {
final int productId;
const BomTab({super.key, required this.productId});
@override
ConsumerState<BomTab> createState() => _BomTabState();
}
class _BomTabState extends ConsumerState<BomTab> {
bool _isLoading = true;
List<ProductBom> _bomItems = [];
@override
void initState() {
super.initState();
_loadBom();
}
Future<void> _loadBom() async {
setState(() => _isLoading = true);
try {
final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId);
setState(() {
_bomItems = items;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading BOM: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showAddComponentSheet() {
final productsState = ref.read(productsProvider);
final availableProducts = (productsState.value ?? []).where((p) => p.id != widget.productId).toList();
Product? selectedProduct;
final qtyCtrl = TextEditingController(text: '1');
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 24,
right: 24,
top: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Add Component", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
SmartSearchDropdown<Product>(
hintText: 'Select Product',
value: selectedProduct,
items: availableProducts,
itemAsString: (p) => p.name,
onChanged: (val) {
setSheetState(() {
selectedProduct = val;
});
},
),
const SizedBox(height: 16),
TextField(
controller: qtyCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Quantity Required',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
if (selectedProduct == null) return;
final qty = double.tryParse(qtyCtrl.text) ?? 1.0;
final bomItem = ProductBom(
parentProductId: widget.productId,
componentProductId: selectedProduct!.id!,
quantity: qty,
);
try {
await ref.read(productsProvider.notifier).addBomItem(widget.productId, bomItem);
if (context.mounted) Navigator.pop(context);
_loadBom();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding component: $e')));
}
}
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: const Text('Add to BOM'),
),
),
const SizedBox(height: 24),
],
),
);
}
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
final productsState = ref.watch(productsProvider);
final allProducts = productsState.value ?? [];
return Stack(
children: [
if (_bomItems.isEmpty)
const Center(child: Text("No components added yet.", style: TextStyle(color: Colors.grey)))
else
ListView.builder(
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
itemCount: _bomItems.length,
itemBuilder: (context, index) {
final item = _bomItems[index];
final component = allProducts.firstWhere(
(p) => p.id == item.componentProductId,
orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'),
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
title: Text(component.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('Quantity Required: ${item.quantity}'),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
try {
await ref.read(productsProvider.notifier).deleteBomItem(item.id!);
_loadBom();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error deleting component: $e')));
}
}
},
),
),
);
},
),
Positioned(
bottom: 90,
right: 16,
child: FloatingActionButton.extended(
heroTag: 'add_bom_btn',
onPressed: _showAddComponentSheet,
backgroundColor: Colors.indigo,
icon: const Icon(Icons.add),
label: const Text("Add Component"),
),
),
],
);
}
}

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../providers/product_categories_provider.dart';
import '../domain/category_rate_history.dart';
class DailyRatesScreen extends ConsumerStatefulWidget {
const DailyRatesScreen({super.key});
@override
ConsumerState<DailyRatesScreen> createState() => _DailyRatesScreenState();
}
class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
final Map<int, TextEditingController> _rateControllers = {};
final Map<int, bool> _isExpanded = {};
final Map<int, List<CategoryRateHistory>> _historyCache = {};
final Map<int, bool> _isLoadingHistory = {};
@override
void dispose() {
for (var controller in _rateControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _fetchHistory(int categoryId) async {
setState(() => _isLoadingHistory[categoryId] = true);
final historyData = await ref.read(productCategoriesProvider.notifier).fetchRateHistory(categoryId);
setState(() {
_historyCache[categoryId] = historyData.map((e) => CategoryRateHistory.fromJson(e)).toList();
_isLoadingHistory[categoryId] = false;
});
}
void _toggleExpand(int categoryId) {
setState(() {
_isExpanded[categoryId] = !(_isExpanded[categoryId] ?? false);
});
if (_isExpanded[categoryId] == true && _historyCache[categoryId] == null) {
_fetchHistory(categoryId);
}
}
Future<void> _saveRate(int categoryId) async {
final text = _rateControllers[categoryId]?.text;
if (text == null || text.isEmpty) return;
final rate = double.tryParse(text);
if (rate == null) return;
try {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
await ref.read(productCategoriesProvider.notifier).updateCategoryRate(categoryId, rate);
if (mounted) {
Navigator.pop(context); // close loading
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Rate updated successfully!'), backgroundColor: Colors.green),
);
_fetchHistory(categoryId); // refresh history
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error updating rate: $e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _syncRates(int categoryId) async {
try {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final count = await ref.read(productCategoriesProvider.notifier).syncRates(categoryId);
if (mounted) {
Navigator.pop(context); // close loading
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Successfully synced rates for $count products!'), backgroundColor: Colors.green),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error syncing rates: $e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
centerTitle: true,
),
body: categoriesState.when(
data: (categories) {
final commodities = categories.where((c) => c.isCommodity).toList();
if (commodities.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.boxes, size: 64, color: Colors.grey.shade300),
const SizedBox(height: 16),
Text('No Commodity Categories', style: TextStyle(fontSize: 18, color: Colors.grey.shade600)),
const SizedBox(height: 8),
Text('Mark a category as a commodity to manage its daily rates.', style: TextStyle(color: Colors.grey.shade500)),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: commodities.length,
itemBuilder: (context, index) {
final category = commodities[index];
if (!_rateControllers.containsKey(category.id)) {
_rateControllers[category.id!] = TextEditingController(text: category.dailyRate?.toString() ?? '');
}
final controller = _rateControllers[category.id]!;
final isExpanded = _isExpanded[category.id] ?? false;
final history = _historyCache[category.id];
final isLoadingHistory = _isLoadingHistory[category.id] ?? false;
DateTime? lastSyncDate;
if (history != null && history.isNotEmpty) {
lastSyncDate = history.first.updatedAt;
}
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
],
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
children: [
// Main Card Header
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(LucideIcons.trendingUp, color: Colors.blue.shade700, size: 24),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
if (lastSyncDate != null)
Text(
'Last updated: ${DateFormat('MMM dd, yyyy HH:mm').format(lastSyncDate)}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
)
else
Text(
'Base Unit: ${category.baseUnit}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
],
),
IconButton(
icon: Icon(isExpanded ? LucideIcons.chevronUp : LucideIcons.history, color: Colors.grey.shade600),
onPressed: () => _toggleExpand(category.id!),
tooltip: 'View History',
),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
flex: 3,
child: TextFormField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Today\'s Rate (per ${category.baseUnit})',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: () => _saveRate(category.id!),
icon: const Icon(LucideIcons.save, size: 18),
label: const Text('Save'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _syncRates(category.id!),
icon: Icon(LucideIcons.refreshCw, size: 18, color: Colors.blue.shade700),
label: const Text('Sync Rate to All Products', style: TextStyle(fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.blue.shade700,
side: BorderSide(color: Colors.blue.shade700),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
),
// Expandable History Section
if (isExpanded)
Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Rate History', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 12),
if (isLoadingHistory)
const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
else if (history == null || history.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('No history found.'),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: history.length > 5 ? 5 : history.length, // Show up to 5 recent
separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300),
itemBuilder: (context, idx) {
final item = history[idx];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(DateFormat('MMM dd, yyyy').format(item.date), style: const TextStyle(fontWeight: FontWeight.w500)),
Text('${item.rate.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
);
},
),
],
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
import 'package:kifi_app/features/inventory/presentation/bom_tab.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
class ProductDetailScreen extends ConsumerWidget {
final Product product;
const ProductDetailScreen({super.key, required this.product});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch products to get latest updates (like stock changes)
final productsState = ref.watch(productsProvider);
final currentProduct = productsState.value?.firstWhere((p) => p.id == product.id, orElse: () => product) ?? product;
return DefaultTabController(
length: 3,
child: Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.black),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddProductScreen(product: currentProduct),
),
);
},
),
],
bottom: const TabBar(
labelColor: Colors.blue,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.blue,
tabs: [
Tab(text: "Overview"),
Tab(text: "BOM"),
Tab(text: "Stock Ledger"),
],
),
),
body: TabBarView(
children: [
_buildOverviewTab(context, currentProduct, ref),
BomTab(productId: currentProduct.id!),
StockLedgerTab(productId: currentProduct.id!),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AdjustStockSheet(productId: currentProduct.id!),
);
},
backgroundColor: Colors.blue,
icon: const Icon(Icons.inventory),
label: const Text("Adjust Stock"),
),
),
);
}
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Header
if (p.imageIds.isNotEmpty)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[200],
),
clipBehavior: Clip.antiAlias,
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final token = snapshot.data;
if (token == null) {
return const Icon(Icons.image, size: 50, color: Colors.grey);
}
return Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) => const Icon(Icons.image, size: 50, color: Colors.grey),
);
},
),
),
const SizedBox(height: 24),
// Stock Summary Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.inventory_2, color: Colors.blue),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Current Stock", style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Text(
"${p.currentStock ?? 0}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Details Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Product Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const Divider(),
_buildDetailRow("SKU", p.sku ?? "-"),
_buildDetailRow("Purchase Price", "${p.purchasePrice?.toStringAsFixed(2) ?? '0.00'}"),
_buildDetailRow("Selling Price", "${p.sellingPrice?.toStringAsFixed(2) ?? '0.00'}"),
_buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"),
_buildDetailRow("Reorder Level", "${p.reorderLevel ?? 0}"),
],
),
),
),
],
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
}
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart';
import 'add_product_screen.dart';
import 'product_detail_screen.dart';
import 'daily_rates_screen.dart';
import '../../../core/network/dio_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -17,7 +20,7 @@ class ProductListScreen extends ConsumerStatefulWidget {
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
Timer? _debounce;
String? _token;
@override
@@ -36,6 +39,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
@@ -54,7 +58,18 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
appBar: AppBar(
title: const Text('Products Catalog'),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
IconButton(
icon: const Icon(LucideIcons.trendingUp),
tooltip: 'Daily Commodity Rates',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const DailyRatesScreen()),
);
},
),
],
),
body: Column(
children: [
@@ -73,8 +88,9 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
),
),
onChanged: (val) {
setState(() {
_searchQuery = val.toLowerCase();
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(productsProvider.notifier).search(val);
});
},
),
@@ -84,12 +100,11 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (products) {
final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList();
if (filtered.isEmpty) {
if (products.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 100),
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
@@ -102,17 +117,15 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: filtered.length + 1, // +1 for loading indicator
physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator
padding: const EdgeInsets.symmetric(horizontal: 16),
itemBuilder: (context, index) {
if (index == filtered.length) {
// We reached the end of the filtered list, show a loader if we are loading more
// The notifier state doesn't expose _isLoadingMore cleanly without another property,
// but if we are at the end, we can just return a tiny spacer.
if (index == products.length) {
return const SizedBox(height: 80);
}
final p = filtered[index];
final p = products[index];
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
return Card(
@@ -122,7 +135,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p)));
},
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -162,7 +175,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
),
const SizedBox(height: 4),
Text(
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
p.trackInventory ? 'Stock: ${p.currentStock ?? 0}' : 'Untracked',
style: TextStyle(
color: p.trackInventory ? Colors.orange : Colors.grey,
fontSize: 12,

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class QuickAdjustStockScreen extends ConsumerStatefulWidget {
const QuickAdjustStockScreen({super.key});
@override
ConsumerState<QuickAdjustStockScreen> createState() => _QuickAdjustStockScreenState();
}
class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _debounce;
String? _token;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadToken();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage();
}
}
Future<void> _loadToken() async {
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
if (mounted) {
setState(() => _token = token);
}
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final productsState = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Quick Adjust Stock'),
elevation: 0,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search product to adjust...',
prefixIcon: const Icon(LucideIcons.search),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(productsProvider.notifier).search(val);
});
},
),
),
Expanded(
child: productsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (products) {
if (products.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 100),
Center(child: Text('No products found.')),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
itemCount: products.length + 1,
itemBuilder: (context, index) {
if (index == products.length) {
return const SizedBox(height: 80);
}
final p = products[index];
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
leading: _buildProductImage(p),
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(p.sku ?? 'No SKU', style: TextStyle(color: Colors.grey.shade600)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${p.currentStock ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.blue),
),
const Text('in stock', style: TextStyle(fontSize: 10, color: Colors.grey)),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AdjustStockSheet(productId: p.id!),
);
},
),
);
},
),
);
},
),
),
],
),
);
}
Widget _buildProductImage(product) {
if (product.imageIds.isEmpty) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(LucideIcons.package, color: Colors.grey),
);
}
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 50,
height: 50,
color: Colors.grey[200],
child: _token == null
? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network(
imageUrl,
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey),
),
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:intl/intl.dart';
class StockLedgerTab extends ConsumerStatefulWidget {
final int productId;
const StockLedgerTab({super.key, required this.productId});
@override
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
}
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<StockMovement>? _ledger;
bool _isLoading = true;
@override
void initState() {
super.initState();
_fetchLedger();
}
Future<void> _fetchLedger() async {
try {
final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId);
if (mounted) {
setState(() {
_ledger = ledger;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_ledger == null || _ledger!.isEmpty) {
return const Center(child: Text("No stock movements recorded."));
}
return RefreshIndicator(
onRefresh: _fetchLedger,
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _ledger!.length,
itemBuilder: (context, index) {
final movement = _ledger![index];
final isAddition = movement.type == 'ADDITION' || movement.type == 'OPENING';
final qty = movement.items?.isNotEmpty == true ? movement.items!.first.quantity : 0.0;
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isAddition ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
isAddition ? Icons.arrow_downward : Icons.arrow_upward,
color: isAddition ? Colors.green : Colors.red,
),
),
title: Text(movement.type, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(
movement.createdAt != null ? DateFormat('dd MMM yyyy, hh:mm a').format(movement.createdAt!) : '',
style: TextStyle(color: Colors.grey[600]),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isAddition ? Colors.green : Colors.red,
),
),
if (movement.notes != null && movement.notes!.isNotEmpty)
Text(
movement.notes!,
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,180 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/uoms_provider.dart';
import 'widgets/add_uom_sheet.dart';
class UomsListScreen extends ConsumerStatefulWidget {
const UomsListScreen({super.key});
@override
ConsumerState<UomsListScreen> createState() => _UomsListScreenState();
}
class _UomsListScreenState extends ConsumerState<UomsListScreen> {
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final uomsState = ref.watch(uomsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Units of Measure'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search units of measure...',
prefixIcon: const Icon(LucideIcons.search),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(uomsProvider.notifier).search(val);
});
},
),
),
Expanded(
child: uomsState.when(
data: (uoms) {
if (uoms.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 100),
Icon(LucideIcons.ruler, size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Center(child: Text('No Units of Measure found', style: TextStyle(color: Colors.grey[600], fontSize: 16))),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: uoms.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final uom = uoms[index];
return Dismissible(
key: ValueKey(uom.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete UOM'),
content: Text('Are you sure you want to delete ${uom.name}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete', style: TextStyle(color: Colors.red))),
],
),
);
},
onDismissed: (direction) {
ref.read(uomsProvider.notifier).deleteUom(uom.id!);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))
],
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
title: Text(uom.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('Abbreviation: ${uom.abbreviation}'),
),
trailing: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.chevronRight, size: 20),
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddUomSheet(uom: uom),
);
},
),
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $e', style: const TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: const Text('Retry'),
)
],
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const AddUomSheet(),
);
},
child: const Icon(LucideIcons.plus),
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/uom.dart';
import '../../providers/uoms_provider.dart';
class AddUomSheet extends ConsumerStatefulWidget {
final UnitOfMeasure? uom;
const AddUomSheet({super.key, this.uom});
@override
ConsumerState<AddUomSheet> createState() => _AddUomSheetState();
}
class _AddUomSheetState extends ConsumerState<AddUomSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _abbrevController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.uom?.name ?? '');
_abbrevController = TextEditingController(text: widget.uom?.abbreviation ?? '');
}
@override
void dispose() {
_nameController.dispose();
_abbrevController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final uom = UnitOfMeasure(
id: widget.uom?.id,
name: _nameController.text.trim(),
abbreviation: _abbrevController.text.trim(),
);
if (widget.uom == null) {
await ref.read(uomsProvider.notifier).createUom(uom);
} else {
await ref.read(uomsProvider.notifier).updateUom(widget.uom!.id!, uom);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(widget.uom == null ? 'Unit of Measure created' : 'Unit of Measure updated')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
top: 24,
left: 24,
right: 24,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.uom == null ? "Add Unit of Measure" : "Edit Unit of Measure", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: "Name (e.g., Kilogram)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]
),
validator: (val) => val == null || val.isEmpty ? 'Please enter a name' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _abbrevController,
decoration: InputDecoration(
labelText: "Abbreviation (e.g., kg)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]
),
validator: (val) => val == null || val.isEmpty ? 'Please enter an abbreviation' : null,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.uom == null ? "Save UOM" : "Update UOM", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
class AdjustStockSheet extends ConsumerStatefulWidget {
final int productId;
const AdjustStockSheet({super.key, required this.productId});
@override
ConsumerState<AdjustStockSheet> createState() => _AdjustStockSheetState();
}
class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
String _selectedType = 'ADDITION';
final _qtyController = TextEditingController();
final _notesController = TextEditingController();
bool _isLoading = false;
final List<String> _types = ['ADDITION', 'REDUCTION', 'DAMAGE', 'ADJUSTMENT'];
Future<void> _submit() async {
final qtyText = _qtyController.text.trim();
if (qtyText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a quantity')));
return;
}
final qty = double.tryParse(qtyText);
if (qty == null || qty <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Quantity must be greater than 0')));
return;
}
setState(() => _isLoading = true);
try {
final movement = StockMovement(
type: _selectedType,
notes: _notesController.text.trim(),
items: [
StockMovementItem(quantity: qty)
]
);
await ref.read(productsProvider.notifier).adjustStock(widget.productId, movement);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Stock adjusted successfully')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
top: 24,
left: 24,
right: 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("Adjust Stock", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedType,
decoration: InputDecoration(
labelText: "Movement Type",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (val) {
if (val != null) setState(() => _selectedType = val);
},
),
const SizedBox(height: 16),
TextFormField(
controller: _qtyController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: "Quantity",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: const Icon(Icons.inventory_2),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _notesController,
decoration: InputDecoration(
labelText: "Notes (Optional)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: const Icon(Icons.note),
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text("Save Adjustment", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
);
}
}

View File

@@ -76,6 +76,56 @@ class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
rethrow;
}
}
Future<void> updateCategoryRate(int categoryId, double rate) async {
try {
final category = state.value?.firstWhere((c) => c.id == categoryId);
if (category == null) return;
final updatedCategory = ProductCategory(
id: category.id,
userId: category.userId,
name: category.name,
parentCategoryId: category.parentCategoryId,
isCommodity: category.isCommodity,
calculationMethod: category.calculationMethod,
baseUnit: category.baseUnit,
dailyRate: rate,
);
await DioClient().dio.put(
'/inventory/categories/$categoryId',
data: updatedCategory.toJson(),
);
state = AsyncValue.data(await _fetchCategories());
} catch (e) {
rethrow;
}
}
Future<int> syncRates(int categoryId) async {
try {
final response = await DioClient().dio.post('/inventory/categories/$categoryId/sync-rates');
if (response.statusCode == 200 && response.data != null) {
return response.data['syncedCount'] ?? 0;
}
return 0;
} catch (e) {
rethrow;
}
}
Future<List<dynamic>> fetchRateHistory(int categoryId) async {
try {
final response = await DioClient().dio.get('/inventory/categories/$categoryId/rate-history');
if (response.statusCode == 200) {
return response.data as List<dynamic>;
}
return [];
} catch (e) {
return [];
}
}
}
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {

View File

@@ -1,16 +1,21 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io';
import '../../../core/network/dio_client.dart';
import '../domain/product.dart';
import '../domain/stock_movement.dart';
import '../domain/product_bom.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
class ProductsNotifier extends AsyncNotifier<List<Product>> {
int _currentPage = 0;
bool _hasMore = true;
bool _isLoadingMore = false;
final int _pageSize = 20;
final int _pageSize = 50;
String _currentSearchQuery = '';
@override
FutureOr<List<Product>> build() async {
@@ -22,10 +27,20 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
final response = await DioClient().dio.get(
'/inventory/products',
queryParameters: {'page': page, 'size': size}
queryParameters: {
'page': page,
'size': size,
if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery,
}
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
if (data.isNotEmpty) {
try {
final file = File('/Users/maddy/Projects/Kifi/kifi-app/debug_product.json');
await file.writeAsString(jsonEncode(data.first));
} catch (_) {}
}
final products = data.map((e) => Product.fromJson(e)).toList();
if (products.length < size) {
_hasMore = false;
@@ -64,6 +79,11 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
}
}
Future<void> search(String query) async {
_currentSearchQuery = query;
await refresh();
}
Future<void> createProduct(Product product, {List<XFile>? images}) async {
try {
final response = await DioClient().dio.post(
@@ -74,8 +94,15 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
if (images != null && images.isNotEmpty && response.data != null) {
final productId = response.data['id'];
for (var image in images) {
final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 800,
quality: 80,
);
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(image.path, filename: image.name),
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
});
await DioClient().dio.post(
'/inventory/products/$productId/images',
@@ -100,8 +127,15 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
if (newImages != null && newImages.isNotEmpty) {
for (var image in newImages) {
final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 800,
quality: 80,
);
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(image.path, filename: image.name),
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
});
await DioClient().dio.post(
'/inventory/products/$id/images',
@@ -116,6 +150,64 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
rethrow;
}
}
Future<void> adjustStock(int productId, StockMovement movement) async {
try {
await DioClient().dio.post(
'/inventory/products/$productId/movements',
data: movement.toJson(),
);
// Refresh the products list to get the updated currentStock
await refresh();
} catch (e) {
throw Exception('Failed to adjust stock: $e');
}
}
Future<List<StockMovement>> fetchStockLedger(int productId) async {
try {
final response = await DioClient().dio.get('/inventory/products/$productId/movements');
if (response.data != null) {
final List<dynamic> data = response.data;
return data.map((e) => StockMovement.fromJson(e)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to fetch stock ledger: $e');
}
}
Future<List<ProductBom>> fetchProductBom(int productId) async {
try {
final response = await DioClient().dio.get('/inventory/products/$productId/bom');
if (response.data != null) {
final List<dynamic> data = response.data;
return data.map((e) => ProductBom.fromJson(e)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to fetch product BOM: $e');
}
}
Future<void> addBomItem(int parentProductId, ProductBom bomItem) async {
try {
await DioClient().dio.post(
'/inventory/products/$parentProductId/bom',
data: bomItem.toJson(),
);
} catch (e) {
throw Exception('Failed to add BOM item: $e');
}
}
Future<void> deleteBomItem(int bomId) async {
try {
await DioClient().dio.delete('/inventory/products/bom/$bomId');
} catch (e) {
throw Exception('Failed to delete BOM item: $e');
}
}
}
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {

View File

@@ -0,0 +1,96 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/uom.dart';
class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
List<UnitOfMeasure> _allUoms = [];
@override
Future<List<UnitOfMeasure>> build() async {
return _fetchUoms();
}
Future<List<UnitOfMeasure>> _fetchUoms() async {
final response = await DioClient().dio.get('/inventory/uom').timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('Connection timed out'),
);
if (response.data == null || response.data.toString().isEmpty) {
_allUoms = [];
return [];
}
final list = (response.data as List).map((e) => UnitOfMeasure.fromJson(e)).toList();
_allUoms = list;
return list;
}
void search(String query) {
if (query.isEmpty) {
state = AsyncValue.data(_allUoms);
return;
}
final lowerQuery = query.toLowerCase();
final filtered = _allUoms.where((uom) =>
uom.name.toLowerCase().contains(lowerQuery) ||
(uom.abbreviation?.toLowerCase().contains(lowerQuery) ?? false)
).toList();
state = AsyncValue.data(filtered);
}
Future<void> fetchUoms() async {
try {
final data = await _fetchUoms();
state = AsyncValue.data(data);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> createUom(UnitOfMeasure uom) async {
try {
final response = await DioClient().dio.post('/inventory/uom', data: uom.toJson());
final newUom = UnitOfMeasure.fromJson(response.data);
if (state is AsyncData) {
_allUoms = [..._allUoms, newUom];
state = AsyncValue.data([...state.value!, newUom]);
} else {
await fetchUoms();
}
} catch (e) {
throw Exception('Failed to create UOM: $e');
}
}
Future<void> updateUom(int id, UnitOfMeasure uom) async {
try {
final response = await DioClient().dio.put('/inventory/uom/$id', data: uom.toJson());
final updatedUom = UnitOfMeasure.fromJson(response.data);
if (state is AsyncData) {
_allUoms = _allUoms.map((e) => e.id == id ? updatedUom : e).toList();
state = AsyncValue.data(
state.value!.map((e) => e.id == id ? updatedUom : e).toList(),
);
}
} catch (e) {
throw Exception('Failed to update UOM: $e');
}
}
Future<void> deleteUom(int id) async {
try {
await DioClient().dio.delete('/inventory/uom/$id');
if (state is AsyncData) {
_allUoms = _allUoms.where((e) => e.id != id).toList();
state = AsyncValue.data(
state.value!.where((e) => e.id != id).toList(),
);
}
} catch (e) {
throw Exception('Failed to delete UOM: $e');
}
}
}
final uomsProvider = AsyncNotifierProvider<UomsNotifier, List<UnitOfMeasure>>(() {
return UomsNotifier();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,789 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:screenshot/screenshot.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../../inventory/providers/products_provider.dart';
import '../domain/invoice.dart';
import '../providers/invoices_provider.dart';
import '../providers/customers_provider.dart';
import '../../business/providers/business_provider.dart';
import 'widgets/receive_payment_sheet.dart';
class InvoiceDetailsScreen extends ConsumerStatefulWidget {
final Invoice invoice;
const InvoiceDetailsScreen({super.key, required this.invoice});
@override
ConsumerState<InvoiceDetailsScreen> createState() => _InvoiceDetailsScreenState();
}
class _InvoiceDetailsScreenState extends ConsumerState<InvoiceDetailsScreen> {
final ScreenshotController _screenshotController = ScreenshotController();
void _showReceivePaymentSheet(BuildContext context, WidgetRef ref, Invoice latestInvoice) async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ReceivePaymentSheet(invoice: latestInvoice),
);
if (result == true) {
ref.read(invoicesProvider.notifier).refresh();
}
}
@override
Widget build(BuildContext context) {
final invoicesState = ref.watch(invoicesProvider);
final latestInvoice = invoicesState.value?.firstWhere(
(i) => i.id == widget.invoice.id,
orElse: () => widget.invoice,
) ?? widget.invoice;
final customersState = ref.watch(customersProvider);
final customer = customersState.value?.firstWhere(
(c) => c.id == latestInvoice.customerId,
orElse: () => null as dynamic,
);
final businessState = ref.watch(businessProfileProvider);
final business = businessState.value;
// Watch products so the UI rebuilds if products are loaded asynchronously
ref.watch(productsProvider);
final formatCurrency = NumberFormat.currency(symbol: '');
final formatDate = DateFormat('dd MMM yyyy');
double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid;
return Scaffold(
backgroundColor: Colors.grey[50],
appBar: AppBar(
title: Text('Invoice #${latestInvoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
centerTitle: true,
actions: [
IconButton(
icon: const Icon(LucideIcons.share2, size: 20),
onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber),
),
],
),
body: SingleChildScrollView(
child: Screenshot(
controller: _screenshotController,
child: Container(
color: Colors.grey[50],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Biller Header Card (Premium Dark)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade900, Colors.blue.shade800],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.blue.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
business?.businessName ?? 'Your Company Name',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
latestInvoice.status,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1),
),
),
],
),
const SizedBox(height: 16),
if (business?.address != null && business!.address!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(LucideIcons.mapPin, size: 14, color: Colors.blue.shade200),
const SizedBox(width: 8),
Expanded(child: Text(business.address!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13, height: 1.4))),
],
),
),
if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(LucideIcons.phone, size: 14, color: Colors.blue.shade200),
const SizedBox(width: 8),
Text(business.contactNumber!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13)),
],
),
),
if (business?.gstin != null && business!.gstin!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(LucideIcons.building, size: 14, color: Colors.blue.shade200),
const SizedBox(width: 8),
Text('GSTIN: ${business.gstin}', style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)),
],
),
),
],
),
),
const SizedBox(height: 20),
// Invoice Dates & Customer Details Row
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Dates
Expanded(
flex: 2,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
const SizedBox(height: 4),
Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue)),
const SizedBox(height: 16),
const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
const SizedBox(height: 4),
Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
const Text('DUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
const SizedBox(height: 4),
Text(latestInvoice.dueDate != null ? formatDate.format(latestInvoice.dueDate!) : '-', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
),
),
),
const SizedBox(width: 12),
// Bill To
Expanded(
flex: 3,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)),
const SizedBox(height: 8),
if (customer != null) ...[
Text(customer.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, height: 1.2)),
const SizedBox(height: 4),
if (customer.address != null && customer.address!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(customer.address!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.3)),
),
if (customer.phone != null && customer.phone!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(customer.phone!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
),
if (customer.gstin != null && customer.gstin!.isNotEmpty)
Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)),
] else ...[
const Text('Walk-in Customer', style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic, color: Colors.grey)),
]
],
),
),
),
],
),
),
const SizedBox(height: 24),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text('Itemized Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 12),
// Item Cards instead of DataTable
...(latestInvoice.items ?? []).map((item) {
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
final skuToDisplay = item.sku ?? product?.sku;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.03), blurRadius: 5, offset: const Offset(0, 2)),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
item.description ?? 'Item',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
Text(
formatCurrency.format(item.total),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
),
],
),
if (skuToDisplay != null && skuToDisplay.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2)} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade800)),
Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
],
),
if (item.makingCharge > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('+ Making Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
Text(formatCurrency.format(item.makingCharge), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
),
),
if (item.otherCharges > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('+ Other Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
Text(formatCurrency.format(item.otherCharges), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
),
),
if (item.discount > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('- Discount', style: TextStyle(fontSize: 12, color: Colors.green.shade600)),
Text('-${formatCurrency.format(item.discount)}', style: TextStyle(fontSize: 12, color: Colors.green.shade600, fontWeight: FontWeight.w600)),
],
),
),
if (item.taxRate > 0)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('+ Tax (${item.taxRate}%)', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
Text(formatCurrency.format(item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
],
),
),
],
),
),
],
),
),
);
}),
const SizedBox(height: 12),
// Summary Section
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Column(
children: [
_buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)),
if (latestInvoice.discountTotal > 0)
_buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green.shade700),
if (latestInvoice.taxTotal > 0)
_buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Divider(height: 1, color: Colors.grey),
),
_buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18, color: Colors.black),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildSummaryRow('Amount Paid', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green.shade700, isBold: true),
const SizedBox(height: 8),
_buildSummaryRow('Balance Due', formatCurrency.format(remaining),
color: remaining > 0 ? Colors.red.shade700 : Colors.green.shade700,
isBold: true,
fontSize: 16
),
],
),
),
],
),
),
const SizedBox(height: 24),
// Payment Info
FutureBuilder<List<InvoicePayment>>(
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!),
builder: (context, snapshot) {
final payments = snapshot.data ?? [];
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
if (displayMethod == null && !latestInvoice.isEmi) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (displayMethod != null) ...[
Row(
children: [
const Icon(LucideIcons.creditCard, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Text('Payment Mode: $displayMethod', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.blue)),
],
),
],
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
if (displayMethod != null) const SizedBox(height: 12),
Row(
children: [
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple),
const SizedBox(width: 8),
Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)),
],
),
if (latestInvoice.nextPaymentDate != null && remaining > 0)
Padding(
padding: const EdgeInsets.only(left: 24, top: 4),
child: Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade700)),
),
],
],
),
);
},
),
// Bottom Spacing for FAB
const SizedBox(height: 100),
],
),
),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: (remaining > 0 && latestInvoice.status != 'DRAFT')
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: FloatingActionButton.extended(
onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice),
icon: const Icon(LucideIcons.indianRupee),
label: const Text('Receive Payment', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
backgroundColor: Colors.blue,
elevation: 4,
),
),
)
: null,
);
}
Widget _buildSummaryRow(String label, String value, {bool isBold = false, Color? color, double fontSize = 14}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w500, color: color ?? Colors.grey.shade600)),
Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color ?? Colors.black87)),
],
),
);
}
void _showShareOptions(BuildContext context, String invoiceNumber) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Share Invoice $invoiceNumber', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
ListTile(
leading: const Icon(LucideIcons.image, color: Colors.blue),
title: const Text('Share as Image'),
subtitle: const Text('Best for WhatsApp, precise look'),
onTap: () {
Navigator.pop(ctx);
_shareAsImage(invoiceNumber);
},
),
ListTile(
leading: const Icon(LucideIcons.fileText, color: Colors.red),
title: const Text('Share as PDF'),
subtitle: const Text('Professional document format'),
onTap: () {
Navigator.pop(ctx);
_shareAsPdf(invoiceNumber);
},
),
const SizedBox(height: 16),
],
),
),
);
}
Future<void> _shareAsImage(String invoiceNumber) async {
try {
final Uint8List? image = await _screenshotController.capture(pixelRatio: 3.0);
if (image == null) return;
final directory = await getTemporaryDirectory();
final imagePath = await File('${directory.path}/Invoice_$invoiceNumber.png').create();
await imagePath.writeAsBytes(image);
await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber');
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e')));
}
}
Future<void> _shareAsPdf(String invoiceNumber) async {
try {
final invoicesState = ref.read(invoicesProvider);
final latestInvoice = invoicesState.value?.firstWhere(
(i) => i.id == widget.invoice.id,
orElse: () => widget.invoice,
) ?? widget.invoice;
final customers = ref.read(customersProvider).value ?? [];
final customer = customers.where((c) => c.id == latestInvoice.customerId).isEmpty
? null
: customers.firstWhere((c) => c.id == latestInvoice.customerId);
final businessState = ref.read(businessProfileProvider);
final business = businessState.value;
final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2);
final formatDate = DateFormat('dd MMM yyyy');
// Fetch payment info
final payments = await ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!);
final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod;
final pdf = pw.Document();
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (pw.Context context) {
return [
// Header
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(business?.businessName ?? 'Your Company', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 4),
if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 12)),
if (business?.contactNumber != null) pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 12)),
if (business?.gstin != null) pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text('INVOICE', style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
pw.SizedBox(height: 8),
pw.Text(latestInvoice.invoiceNumber, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 12)),
if (latestInvoice.dueDate != null)
pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 12)),
pw.SizedBox(height: 4),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))),
child: pw.Text(latestInvoice.status, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
),
],
),
],
),
pw.SizedBox(height: 32),
// Bill To
pw.Text('BILL TO:', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)),
pw.SizedBox(height: 4),
if (customer != null) ...[
pw.Text(customer.name, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
if (customer.address != null) pw.Text(customer.address!, style: const pw.TextStyle(fontSize: 12)),
if (customer.phone != null) pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 12)),
if (customer.gstin != null) pw.Text('GSTIN: ${customer.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)),
] else ...[
pw.Text('Walk-in Customer', style: const pw.TextStyle(fontSize: 14)),
],
pw.SizedBox(height: 32),
// Items Table
pw.TableHelper.fromTextArray(
context: context,
border: const pw.TableBorder(
bottom: pw.BorderSide(color: PdfColors.grey300, width: .5),
horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5),
),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white),
headerDecoration: const pw.BoxDecoration(color: PdfColors.blue800),
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
data: [
['Description', 'Qty', 'Unit Price', 'Total'],
...(latestInvoice.items ?? []).map((item) {
final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null;
final skuToDisplay = item.sku ?? product?.sku;
String itemDesc = item.description ?? 'Item';
if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay';
if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}';
if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}';
if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}';
if (item.taxRate > 0) {
final taxAmt = item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount;
itemDesc += '\n+ Tax (${item.taxRate}%): ${formatCurrency.format(taxAmt)}';
}
return [
itemDesc,
item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2),
formatCurrency.format(item.unitPrice),
formatCurrency.format(item.total),
];
}),
],
),
pw.SizedBox(height: 24),
// Totals
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 250,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Subtotal:'),
pw.Text(formatCurrency.format(latestInvoice.subtotal)),
],
),
pw.SizedBox(height: 4),
if (latestInvoice.discountTotal > 0) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Discount:'),
pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(color: PdfColors.green700)),
],
),
pw.SizedBox(height: 4),
],
if (latestInvoice.taxTotal > 0) ...[
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Tax:'),
pw.Text(formatCurrency.format(latestInvoice.taxTotal)),
],
),
pw.SizedBox(height: 4),
],
pw.Divider(color: PdfColors.grey400),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)),
],
),
pw.SizedBox(height: 12),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)),
pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: pw.TextStyle(color: PdfColors.green700)),
],
),
pw.Divider(color: PdfColors.grey400),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)),
],
),
],
),
),
],
),
pw.SizedBox(height: 24),
if (displayMethod != null || latestInvoice.isEmi)
pw.Container(
padding: const pw.EdgeInsets.all(12),
decoration: pw.BoxDecoration(color: PdfColors.blue50, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8))),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
if (displayMethod != null)
pw.Text('Payment Mode: $displayMethod', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)),
if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[
if (displayMethod != null) pw.SizedBox(height: 4),
pw.Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.purple800)),
if (latestInvoice.nextPaymentDate != null && (latestInvoice.totalAmount - latestInvoice.amountPaid) > 0)
pw.Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700)),
],
],
),
),
pw.SizedBox(height: 40),
// Footer
pw.Divider(color: PdfColors.grey300),
pw.SizedBox(height: 8),
pw.Center(
child: pw.Text('Thank you for your business!', style: pw.TextStyle(color: PdfColors.grey600, fontStyle: pw.FontStyle.italic)),
),
];
},
),
);
final directory = await getTemporaryDirectory();
final pdfPath = await File('${directory.path}/Invoice_$invoiceNumber.pdf').create();
await pdfPath.writeAsBytes(await pdf.save());
await Share.shareXFiles([XFile(pdfPath.path)], text: 'Invoice $invoiceNumber');
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e')));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -256,6 +256,11 @@ class Wallet {
final String? currency;
final String? icon;
final String? color;
final String? subNature;
final double? creditLimit;
final double? fixedAmount;
final String? paymentCycle;
final int? cycleDate;
Wallet({
required this.id,
@@ -266,6 +271,11 @@ class Wallet {
this.currency,
this.icon,
this.color,
this.subNature,
this.creditLimit,
this.fixedAmount,
this.paymentCycle,
this.cycleDate,
});
factory Wallet.fromJson(Map<String, dynamic> json) {
@@ -293,6 +303,11 @@ class Wallet {
currency: json['currency'],
icon: json['icon'],
color: json['color'],
subNature: json['subNature'],
creditLimit: parseDouble(json['creditLimit']),
fixedAmount: parseDouble(json['fixedAmount']),
paymentCycle: json['paymentCycle'],
cycleDate: json['cycleDate'] != null ? parseId(json['cycleDate']) : null,
);
}
@@ -303,6 +318,11 @@ class Wallet {
'initialBalance': balance, // Note: backend uses initialBalance on creation
'icon': icon,
'color': color,
'subNature': subNature,
'creditLimit': creditLimit,
'fixedAmount': fixedAmount,
'paymentCycle': paymentCycle,
'cycleDate': cycleDate,
};
}
class WalletInvitation {

View File

@@ -150,6 +150,11 @@ class ApiRepository {
String currency = 'INR',
String? icon,
String? color,
String? subNature,
double? creditLimit,
double? fixedAmount,
String? paymentCycle,
int? cycleDate,
}) async {
final response = await dio.post('/wallets', data: {
'name': name,
@@ -158,6 +163,11 @@ class ApiRepository {
'currency': currency,
'icon': icon,
'color': color,
if (subNature != null) 'subNature': subNature,
if (creditLimit != null) 'creditLimit': creditLimit,
if (fixedAmount != null) 'fixedAmount': fixedAmount,
if (paymentCycle != null) 'paymentCycle': paymentCycle,
if (cycleDate != null) 'cycleDate': cycleDate,
});
return Wallet.fromJson(response.data);
}
@@ -167,12 +177,22 @@ class ApiRepository {
String? nature,
String? icon,
String? color,
String? subNature,
double? creditLimit,
double? fixedAmount,
String? paymentCycle,
int? cycleDate,
}) async {
final response = await dio.put('/wallets/$id', data: {
if (name != null) 'name': name,
if (nature != null) 'nature': nature,
if (icon != null) 'icon': icon,
if (color != null) 'color': color,
if (subNature != null) 'subNature': subNature,
if (creditLimit != null) 'creditLimit': creditLimit,
if (fixedAmount != null) 'fixedAmount': fixedAmount,
if (paymentCycle != null) 'paymentCycle': paymentCycle,
if (cycleDate != null) 'cycleDate': cycleDate,
});
return Wallet.fromJson(response.data);
}

View File

@@ -144,8 +144,10 @@ final recurringTransactionProvider = AsyncNotifierProvider<RecurringTransactionN
class WalletNotifier extends AsyncNotifier<List<Wallet>> {
@override
FutureOr<List<Wallet>> build() {
return ref.watch(apiRepositoryProvider).getWallets();
FutureOr<List<Wallet>> build() async {
final wallets = await ref.watch(apiRepositoryProvider).getWallets();
wallets.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return wallets;
}
Future<Wallet> createWallet({
@@ -155,6 +157,11 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
String currency = 'INR',
String? icon,
String? color,
String? subNature,
double? creditLimit,
double? fixedAmount,
String? paymentCycle,
int? cycleDate,
}) async {
final newWallet = await ref.read(apiRepositoryProvider).createWallet(
name: name,
@@ -163,9 +170,16 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
currency: currency,
icon: icon,
color: color,
subNature: subNature,
creditLimit: creditLimit,
fixedAmount: fixedAmount,
paymentCycle: paymentCycle,
cycleDate: cycleDate,
);
if (state.value != null) {
state = AsyncValue.data([...state.value!, newWallet]);
final updated = [...state.value!, newWallet];
updated.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
state = AsyncValue.data(updated);
}
return newWallet;
}
@@ -174,18 +188,23 @@ class WalletNotifier extends AsyncNotifier<List<Wallet>> {
await ref.read(apiRepositoryProvider).inviteUserToWallet(walletId, email);
}
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color}) async {
Future<void> editWallet(int id, {String? name, String? nature, String? icon, String? color, String? subNature, double? creditLimit, double? fixedAmount, String? paymentCycle, int? cycleDate}) async {
final updated = await ref.read(apiRepositoryProvider).editWallet(
id: id,
name: name,
nature: nature,
icon: icon,
color: color,
subNature: subNature,
creditLimit: creditLimit,
fixedAmount: fixedAmount,
paymentCycle: paymentCycle,
cycleDate: cycleDate,
);
if (state.value != null) {
state = AsyncValue.data(
state.value!.map((w) => w.id == id ? updated : w).toList(),
);
final updatedList = state.value!.map((w) => w.id == id ? updated : w).toList();
updatedList.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
state = AsyncValue.data(updatedList);
}
}

View File

@@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
printing
url_launcher_linux
)

View File

@@ -10,6 +10,8 @@ import flutter_image_compress_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import local_auth_darwin
import mobile_scanner
import printing
import share_plus
import shared_preferences_foundation
@@ -19,6 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -49,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
@@ -776,6 +792,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
url: "https://pub.dev"
source: hosted
version: "7.4.0"
node_preamble:
dependency: transitive
description:
@@ -808,6 +832,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -856,6 +888,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
url: "https://pub.dev"
source: hosted
version: "3.12.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
@@ -904,6 +952,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.2"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
pub_semver:
dependency: transitive
description:
@@ -912,6 +968,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
record_use:
dependency: transitive
description:
@@ -928,6 +992,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.2"
screenshot:
dependency: "direct main"
description:
name: screenshot
sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
share_plus:
dependency: "direct main"
description:

View File

@@ -53,6 +53,10 @@ dependencies:
timezone: ^0.11.1
encrypt: ^5.0.3
pointycastle: ^3.9.1
mobile_scanner: ^7.4.0
screenshot: ^3.0.0
pdf: ^3.12.0
printing: ^5.14.3
dev_dependencies:
flutter_test:

View File

@@ -9,6 +9,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <printing/printing_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
local_auth_windows
printing
share_plus
url_launcher_windows
)