Product Add Done

This commit is contained in:
2026-08-16 22:51:34 +05:30
parent 17c0526ed6
commit ba9a9fd8a4
60 changed files with 5098 additions and 22 deletions

View File

@@ -15,7 +15,8 @@ class DioClient {
DioClient._internal()
: dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi',
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
//baseUrl: 'http://192.168.0.104:8080/api/kifi-v2', // Local Mac IP for physical device testing
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)),

View File

@@ -12,6 +12,8 @@ import '../providers/auth_provider.dart';
import '../../transactions/providers/providers.dart';
import '../../../core/theme/theme_provider.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../business/presentation/settings/business_settings_screen.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@@ -114,7 +116,37 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
),
),
const Divider(height: 1),
Consumer(
builder: (context, ref, child) {
final isBusinessMode = ref.watch(businessModeProvider);
return Column(
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.briefcase),
title: const Text('Business Mode'),
subtitle: const Text('Inventory and sales management'),
value: isBusinessMode,
onChanged: (val) {
ref.read(businessModeProvider.notifier).toggleMode();
},
),
if (isBusinessMode) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.settings),
title: const Text('Business Settings'),
subtitle: const Text('Tax, Modules, POS'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen()));
},
),
]
],
);
}
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.helpCircle),
title: const Text('Help & Support'),

View File

@@ -0,0 +1,61 @@
class BusinessProfile {
final int? id;
final int? userId;
final String businessName;
final String? industry;
final String? taxNumber;
final String? currency;
final bool? taxIncludedInPrice;
BusinessProfile({
this.id,
this.userId,
required this.businessName,
this.industry,
this.taxNumber,
this.currency,
this.taxIncludedInPrice,
});
factory BusinessProfile.fromJson(Map<String, dynamic> json) {
return BusinessProfile(
id: json['id'],
userId: json['userId'],
businessName: json['businessName'],
industry: json['industry'],
taxNumber: json['taxNumber'],
currency: json['currency'],
taxIncludedInPrice: json['taxIncludedInPrice'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'businessName': businessName,
'industry': industry,
'taxNumber': taxNumber,
'currency': currency,
'taxIncludedInPrice': taxIncludedInPrice,
};
}
BusinessProfile copyWith({
String? businessName,
String? industry,
String? taxNumber,
String? currency,
bool? taxIncludedInPrice,
}) {
return BusinessProfile(
id: id,
userId: userId,
businessName: businessName ?? this.businessName,
industry: industry ?? this.industry,
taxNumber: taxNumber ?? this.taxNumber,
currency: currency ?? this.currency,
taxIncludedInPrice: taxIncludedInPrice ?? this.taxIncludedInPrice,
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
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';
class BusinessHubScreen extends ConsumerWidget {
const BusinessHubScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
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)),
],
),
),
],
),
),
const SizedBox(height: 24),
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
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'),
() {},
),
],
),
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)),
),
),
],
),
);
}
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 12),
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: color.withOpacity(0.7), fontSize: 12)),
],
),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../providers/business_provider.dart';
class BusinessSettingsScreen extends ConsumerStatefulWidget {
const BusinessSettingsScreen({super.key});
@override
ConsumerState<BusinessSettingsScreen> createState() => _BusinessSettingsScreenState();
}
class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen> {
bool _taxIncludedInPrice = false;
bool _salesEnabled = true;
bool _inventoryEnabled = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final profile = ref.read(businessProfileProvider).value;
if (profile != null) {
setState(() {
_taxIncludedInPrice = profile.taxIncludedInPrice ?? false;
});
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Business Settings'),
elevation: 0,
backgroundColor: Colors.transparent,
),
body: ListView(
padding: const EdgeInsets.all(24.0),
children: [
const Text('Module Configuration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Inventory Management'),
subtitle: const Text('Track stock levels, multiple locations, and products'),
value: _inventoryEnabled,
onChanged: (val) => setState(() => _inventoryEnabled = val),
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),
secondary: const Icon(LucideIcons.shoppingCart),
),
const SizedBox(height: 32),
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Selling Price includes Tax'),
subtitle: const Text('If enabled, GST is assumed to be inclusive in the entered selling price.'),
value: _taxIncludedInPrice,
onChanged: (val) {
setState(() => _taxIncludedInPrice = val);
_saveSettings();
},
secondary: const Icon(LucideIcons.receipt),
),
],
),
);
}
void _saveSettings() {
final profileState = ref.read(businessProfileProvider).value;
if (profileState != null) {
final updated = profileState.copyWith(taxIncludedInPrice: _taxIncludedInPrice);
ref.read(businessProfileProvider.notifier).updateProfile(updated);
}
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class BusinessModeNotifier extends Notifier<bool> {
@override
bool build() {
_loadState();
return false; // Default until loaded
}
Future<void> _loadState() async {
final prefs = await SharedPreferences.getInstance();
state = prefs.getBool('is_business_mode') ?? false;
}
Future<void> toggleMode() async {
final prefs = await SharedPreferences.getInstance();
state = !state;
await prefs.setBool('is_business_mode', state);
}
}
final businessModeProvider = NotifierProvider<BusinessModeNotifier, bool>(() {
return BusinessModeNotifier();
});

View File

@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/business_profile.dart';
class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
@override
FutureOr<BusinessProfile?> build() async {
return _fetchProfile();
}
Future<BusinessProfile?> _fetchProfile() async {
final response = await DioClient().dio.get('/business/profile');
if (response.statusCode == 200) {
return BusinessProfile.fromJson(response.data);
}
return null;
}
Future<void> updateProfile(BusinessProfile profile) async {
state = const AsyncValue.loading();
try {
final response = await DioClient().dio.post(
'/business/profile',
data: profile.toJson(),
);
if (response.statusCode == 200) {
state = AsyncValue.data(BusinessProfile.fromJson(response.data));
}
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
}
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
return BusinessProfileNotifier();
});

View File

@@ -19,6 +19,8 @@ import '../../../core/theme/nature_colors.dart';
import 'wallet_ledger_screen.dart';
import 'maturity_dialog.dart';
import 'accounts_screen.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../business/presentation/hub/business_hub_screen.dart';
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@@ -137,6 +139,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final categoriesState = ref.watch(categoryProvider);
final insight = ref.watch(insightProvider);
final walletsState = ref.watch(walletProvider);
final isBusinessMode = ref.watch(businessModeProvider);
final allTransactions = transState.value ?? [];
final range = _getDateRange(allTransactions);
@@ -145,7 +148,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
String title;
if (_currentIndex == 0) title = 'Dashboard';
else if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 1) title = isBusinessMode ? 'Business Hub' : 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
@@ -512,8 +515,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
// ---------------- STATS TAB ----------------
StatisticsTab(
// ---------------- STATS/BUSINESS TAB ----------------
isBusinessMode ? const BusinessHubScreen() : StatisticsTab(
transactions: transactions,
wallets: safeWallets,
categories: categoriesState.value ?? [],
@@ -553,12 +556,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
items: [
const BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(isBusinessMode ? LucideIcons.briefcase : LucideIcons.pieChart),
label: isBusinessMode ? 'Business' : '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'),
],
),
);

View File

@@ -0,0 +1,75 @@
class InventoryMovementItem {
final int? id;
final int? movementId;
final int productId;
final double quantity;
final double? unitPrice;
InventoryMovementItem({
this.id,
this.movementId,
required this.productId,
required this.quantity,
this.unitPrice,
});
factory InventoryMovementItem.fromJson(Map<String, dynamic> json) {
return InventoryMovementItem(
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 InventoryMovement {
final int? id;
final int? userId;
final int locationId;
final String type;
final int? referenceTransactionId;
final String? notes;
InventoryMovement({
this.id,
this.userId,
required this.locationId,
required this.type,
this.referenceTransactionId,
this.notes,
});
factory InventoryMovement.fromJson(Map<String, dynamic> json) {
return InventoryMovement(
id: json['id'],
userId: json['userId'],
locationId: json['locationId'],
type: json['type'],
referenceTransactionId: json['referenceTransactionId'],
notes: json['notes'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'locationId': locationId,
'type': type,
'referenceTransactionId': referenceTransactionId,
'notes': notes,
};
}
}

View File

@@ -0,0 +1,120 @@
class Product {
final int? id;
final int? userId;
final int? categoryId;
final int? uomId;
final String name;
final String? sku;
final String? barcode;
final String? description;
final double? purchasePrice;
final double? sellingPrice;
final double? minStock;
final double? reorderLevel;
final double? gstRate;
final String? dimensions;
final double? weight;
final String? color;
final String? size;
final String priceCalcRule;
final bool autoCalculatePrice;
final double? purityFactor;
final double? makingCharges;
final String? makingChargesType;
final double? wastagePercentage;
final bool trackInventory;
final bool isActive;
final List<int> imageIds;
Product({
this.id,
this.userId,
this.categoryId,
this.uomId,
required this.name,
this.sku,
this.barcode,
this.description,
this.purchasePrice,
this.sellingPrice,
this.minStock,
this.reorderLevel,
this.gstRate,
this.dimensions,
this.weight,
this.color,
this.size,
this.priceCalcRule = 'MANUAL',
this.autoCalculatePrice = false,
this.purityFactor = 1.0,
this.makingCharges = 0.0,
this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0,
this.trackInventory = true,
this.isActive = true,
this.imageIds = const [],
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
userId: json['userId'],
categoryId: json['categoryId'],
uomId: json['uomId'],
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(),
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,
imageIds: json['images'] != null
? (json['images'] as List).map((i) => i['id'] as int).toList()
: [],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'categoryId': categoryId,
'uomId': uomId,
'name': name,
'sku': sku,
'barcode': barcode,
'description': description,
'purchasePrice': purchasePrice,
'sellingPrice': sellingPrice,
'minStock': minStock,
'reorderLevel': reorderLevel,
'gstRate': gstRate,
'dimensions': dimensions,
'weight': weight,
'color': color,
'size': size,
'priceCalcRule': priceCalcRule,
'autoCalculatePrice': autoCalculatePrice,
'purityFactor': purityFactor,
'makingCharges': makingCharges,
'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage,
'trackInventory': trackInventory,
'isActive': isActive,
};
}
}

View File

@@ -0,0 +1,812 @@
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 '../domain/product.dart';
import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart';
import '../../business/providers/business_provider.dart';
class AddProductScreen extends ConsumerStatefulWidget {
final Product? product;
const AddProductScreen({super.key, this.product});
@override
ConsumerState<AddProductScreen> createState() => _AddProductScreenState();
}
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final int _totalPages = 4;
final _formKey = GlobalKey<FormState>();
// Basic
String _name = '';
String _sku = '';
ProductCategory? _selectedCategory;
// Properties
String _color = '';
String _size = '';
String _dimensions = '';
double _weight = 0;
// Pricing & Inventory
double _purchasePrice = 0;
double _sellingPrice = 0;
double _gstRate = 0;
bool _trackInventory = true;
bool _autoCalculatePrice = false;
// Advanced Commodity Fields
double _purityFactor = 1.0;
double _makingCharges = 0;
String _makingChargesType = 'FLAT'; // FLAT, PER_UNIT, PERCENTAGE
double _wastagePercentage = 0;
// Media
final List<XFile> _images = [];
bool _isSaving = false;
@override
void initState() {
super.initState();
if (widget.product != null) {
final p = widget.product!;
_name = p.name;
_sku = p.sku ?? '';
_color = p.color ?? '';
_size = p.size ?? '';
_dimensions = p.dimensions ?? '';
_weight = p.weight ?? 0;
_purchasePrice = p.purchasePrice ?? 0;
_sellingPrice = p.sellingPrice ?? 0;
_gstRate = p.gstRate ?? 0;
_trackInventory = p.trackInventory;
_autoCalculatePrice = p.autoCalculatePrice;
_purityFactor = p.purityFactor ?? 1.0;
_makingCharges = p.makingCharges ?? 0.0;
_makingChargesType = p.makingChargesType ?? 'FLAT';
_wastagePercentage = p.wastagePercentage ?? 0.0;
WidgetsBinding.instance.addPostFrameCallback((_) {
final cats = ref.read(productCategoriesProvider).value ?? [];
if (cats.isNotEmpty) {
setState(() {
_selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first);
});
}
});
}
}
void _nextPage() {
FocusScope.of(context).unfocus();
if (_currentPage < _totalPages - 1) {
if (_currentPage == 0 && _selectedCategory == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category.')));
return;
}
_pageController.animateToPage(_currentPage + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
} else {
_save();
}
}
void _prevPage() {
FocusScope.of(context).unfocus();
if (_currentPage > 0) {
_pageController.animateToPage(_currentPage - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
}
Future<void> _pickImages() async {
if (_images.length >= 4) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
return;
}
final ImagePicker picker = ImagePicker();
final List<XFile> picked = await picker.pickMultiImage();
if (picked.isNotEmpty) {
setState(() {
_images.addAll(picked.take(4 - _images.length));
});
}
}
void _removeImage(int index) {
setState(() {
_images.removeAt(index);
});
}
void _showAddCategoryDialog() {
String newCatName = '';
bool newCatCommodity = false;
double newCatRate = 0;
String newCalcMethod = 'UNIT'; // WEIGHT, UNIT, VOLUME
String newBaseUnit = 'pcs';
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: const Text('New Category', style: TextStyle(fontWeight: FontWeight.bold)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildPremiumTextField(
label: 'Category Name',
onChanged: (val) => newCatName = val,
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Is this a Commodity?'),
subtitle: const Text('Enable for daily rate pricing (e.g. Gold)'),
value: newCatCommodity,
onChanged: (val) => setDialogState(() => newCatCommodity = val),
),
if (newCatCommodity) ...[
_buildPremiumDropdown(
label: 'Calculation Method',
value: newCalcMethod,
items: ['UNIT', 'WEIGHT', 'VOLUME'],
onChanged: (val) => setDialogState(() {
newCalcMethod = val!;
if (val == 'WEIGHT') newBaseUnit = 'gm';
else if (val == 'VOLUME') newBaseUnit = 'liter';
else newBaseUnit = 'pcs';
})
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'Base Unit (e.g. gm, kg, pcs)',
initialValue: newBaseUnit,
onChanged: (val) => newBaseUnit = val,
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'Daily Rate',
prefixText: '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => newCatRate = double.tryParse(val) ?? 0,
),
]
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
onPressed: () async {
if (newCatName.trim().isEmpty) return;
final newCategory = ProductCategory(
name: newCatName.trim(),
isCommodity: newCatCommodity,
calculationMethod: newCalcMethod,
baseUnit: newBaseUnit,
dailyRate: newCatCommodity ? newCatRate : null,
);
await ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
if (context.mounted) Navigator.pop(context);
},
child: const Text('Create'),
),
],
);
},
);
},
);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
if (_selectedCategory == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category')));
return;
}
setState(() => _isSaving = true);
try {
final product = Product(
name: _name,
sku: _sku.isNotEmpty ? _sku : 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,
makingCharges: _makingCharges,
makingChargesType: _makingChargesType,
wastagePercentage: _wastagePercentage,
trackInventory: _trackInventory,
);
if (widget.product != null) {
await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images);
} else {
await ref.read(productsProvider.notifier).createProduct(product, images: _images);
}
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
double _calculateLivePrice() {
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
double rate = _selectedCategory!.dailyRate ?? 0;
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
// Base Material Cost = (Weight + Wastage) * Rate * Purity
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
double materialCost = materialWeight * rate * _purityFactor;
// Making Charges
double making = 0;
if (_makingChargesType == 'FLAT') making = _makingCharges;
else if (_makingChargesType == 'PER_UNIT') making = _makingCharges * baseVal;
else if (_makingChargesType == 'PERCENTAGE') making = materialCost * (_makingCharges / 100);
return materialCost + making;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
title: Text(widget.product != null ? 'Edit Product' : 'New Product', style: const TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Column(
children: [
_buildProgressPills(),
Expanded(
child: Form(
key: _formKey,
child: PageView(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
onPageChanged: (idx) {
FocusScope.of(context).unfocus();
setState(() => _currentPage = idx);
},
children: [
_buildBasicStep(),
_buildPropertiesStep(),
_buildPricingStep(),
_buildMediaStep(),
],
),
),
),
_buildBottomBar(),
],
),
),
);
}
Widget _buildProgressPills() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: List.generate(_totalPages, (index) {
bool isActive = index <= _currentPage;
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: EdgeInsets.only(right: index == _totalPages - 1 ? 0 : 8),
height: 6,
decoration: BoxDecoration(
color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(10),
),
),
);
}),
),
);
}
Widget _buildBottomBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
children: [
if (_currentPage > 0)
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: _isSaving ? null : _prevPage,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Icon(LucideIcons.chevronLeft),
),
),
if (_currentPage > 0) const SizedBox(width: 16),
Expanded(
flex: 3,
child: ElevatedButton(
onPressed: _isSaving ? null : _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
shadowColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
),
child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentPage == _totalPages - 1
? (widget.product != null ? 'Update Product' : 'Publish Product')
: 'Continue',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)
),
),
),
],
),
),
);
}
// ==== STEPS ====
Widget _buildBasicStep() {
final categoriesState = ref.watch(productCategoriesProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Basic Information', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Let\'s start with the core details of your product.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
_buildPremiumTextField(
label: 'Product Name*',
initialValue: _name,
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onChanged: (val) => _name = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'SKU / Barcode',
initialValue: _sku,
onChanged: (val) => _sku = val,
),
const SizedBox(height: 32),
const Text('Category', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
categoriesState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading categories: $err'),
data: (categories) {
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leafCategories.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(16)),
child: const Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.orange),
SizedBox(width: 12),
Expanded(child: Text('No categories found. Create one to organize your inventory.', style: TextStyle(color: Colors.orange))),
],
),
)
else
_buildPremiumDropdown<ProductCategory>(
label: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemLabel: (c) {
String displayName = c.name;
if (c.parentCategoryId != null) {
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
displayName = '${parent.name} > ${c.name}';
}
return displayName;
},
onChanged: (val) => setState(() => _selectedCategory = val),
),
const SizedBox(height: 12),
GestureDetector(
onTap: _showAddCategoryDialog,
child: Row(
children: [
Icon(LucideIcons.plusCircle, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text('Create New Category', style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w600)),
],
),
),
],
);
},
),
],
),
);
}
Widget _buildPropertiesStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Properties & Attributes', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Define the physical characteristics.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
_buildPremiumTextField(
label: 'Weight',
initialValue: _weight == 0 ? '' : _weight.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
suffixText: _selectedCategory?.baseUnit ?? 'unit',
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Color',
initialValue: _color,
onChanged: (val) => _color = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Size',
initialValue: _size,
onChanged: (val) => _size = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Dimensions',
initialValue: _dimensions,
onChanged: (val) => _dimensions = val,
),
],
),
);
}
Widget _buildPricingStep() {
final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false;
final isCommodity = _selectedCategory?.isCommodity ?? false;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pricing & Inventory', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Set up costs, pricing rules, and tracking.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
if (isCommodity) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.blue.shade800, Colors.blue.shade500]),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(LucideIcons.activity, color: Colors.white),
const SizedBox(width: 12),
const Text('Commodity Pricing', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
Switch(
value: _autoCalculatePrice,
activeColor: Colors.white,
onChanged: (val) => setState(() => _autoCalculatePrice = val),
),
],
),
const SizedBox(height: 8),
Text('Daily Rate: ₹${_selectedCategory!.dailyRate ?? 0} / ${_selectedCategory!.baseUnit}', style: const TextStyle(color: Colors.white70)),
if (_autoCalculatePrice) ...[
const Divider(color: Colors.white24, height: 32),
_buildPremiumTextField(
label: 'Purity Factor (e.g. 0.916 for 22K)',
initialValue: _purityFactor.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _purityFactor = double.tryParse(val) ?? 1.0),
darkTheme: true,
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'Wastage Percentage (%)',
initialValue: _wastagePercentage.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _wastagePercentage = double.tryParse(val) ?? 0.0),
darkTheme: true,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: _buildPremiumTextField(
label: 'Making Charges',
initialValue: _makingCharges.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _makingCharges = double.tryParse(val) ?? 0.0),
darkTheme: true,
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: _buildPremiumDropdown<String>(
label: 'Type',
value: _makingChargesType,
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
onChanged: (val) => setState(() => _makingChargesType = val!),
darkTheme: true,
),
),
],
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(12)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Live Selling Price:', style: TextStyle(color: Colors.white70)),
Text('${_calculateLivePrice().toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
],
),
)
]
],
),
),
const SizedBox(height: 32),
],
if (!_autoCalculatePrice) ...[
_buildPremiumTextField(
label: 'Purchase Price',
initialValue: _purchasePrice == 0 ? '' : _purchasePrice.toString(),
prefixText: '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => _purchasePrice = double.tryParse(val) ?? 0,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*',
initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(),
prefixText: '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0,
),
const SizedBox(height: 20),
],
_buildPremiumTextField(
label: 'GST Rate (%)',
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
suffixText: '%',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
),
const SizedBox(height: 32),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(16),
),
child: SwitchListTile(
title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Monitor stock levels automatically'),
value: _trackInventory,
onChanged: (val) => setState(() => _trackInventory = val),
),
)
],
),
);
}
Widget _buildMediaStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Product Images', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
if (_images.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: _images.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),
),
),
)
],
);
},
),
const SizedBox(height: 24),
if (_images.length < 4)
GestureDetector(
onTap: _pickImages,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3), style: BorderStyle.solid),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
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)),
],
),
),
),
],
),
);
}
// ==== WIDGET HELPERS ====
Widget _buildPremiumTextField({
required String label,
String? initialValue,
String? prefixText,
String? suffixText,
TextInputType? keyboardType,
void Function(String)? onChanged,
void Function(String?)? onSaved,
String? Function(String?)? validator,
bool darkTheme = false,
}) {
return TextFormField(
initialValue: initialValue,
style: TextStyle(color: darkTheme ? Colors.white : null),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
prefixText: prefixText,
prefixStyle: TextStyle(color: darkTheme ? Colors.white : Colors.black, fontWeight: FontWeight.bold),
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),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
),
),
keyboardType: keyboardType,
onChanged: onChanged,
onSaved: onSaved,
validator: validator,
);
}
Widget _buildPremiumDropdown<T>({
required String label,
required T? value,
required List<T> items,
String Function(T)? itemLabel,
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)
),
),
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,
);
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
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 '../../../core/network/dio_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class ProductListScreen extends ConsumerStatefulWidget {
const ProductListScreen({super.key});
@override
ConsumerState<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
String? _token;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadToken();
}
Future<void> _loadToken() async {
_token = await const FlutterSecureStorage().read(key: 'jwt_token');
setState(() {});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage();
}
}
@override
Widget build(BuildContext context) {
final productsState = ref.watch(productsProvider);
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Products Catalog'),
elevation: 0,
backgroundColor: Colors.transparent,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search products by name or SKU...',
prefixIcon: const Icon(LucideIcons.search),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
onChanged: (val) {
setState(() {
_searchQuery = val.toLowerCase();
});
},
),
),
Expanded(
child: productsState.when(
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) {
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView(
children: const [
SizedBox(height: 100),
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: filtered.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.
return const SizedBox(height: 80);
}
final p = filtered[index];
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
},
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
_buildProductImage(p),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8)),
child: Text(catName, style: const TextStyle(color: Colors.blue, fontSize: 12, fontWeight: FontWeight.bold)),
),
if (p.sku != null && p.sku!.isNotEmpty) ...[
const SizedBox(width: 8),
Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)),
]
],
),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${(p.sellingPrice ?? 0).toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
style: TextStyle(
color: p.trackInventory ? Colors.orange : Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold
),
)
],
),
],
),
),
),
);
},
),
);
},
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen()));
},
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(LucideIcons.plus, color: Colors.white),
),
);
}
Widget _buildProductImage(product) {
if (product.imageIds.isEmpty) {
return Container(
width: 60,
height: 60,
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: 60,
height: 60,
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,83 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
class ProductCategory {
final int? id;
final int? userId;
final String name;
final int? parentCategoryId;
final bool isCommodity;
final String calculationMethod;
final String baseUnit;
final double? dailyRate;
ProductCategory({
this.id,
this.userId,
required this.name,
this.parentCategoryId,
this.isCommodity = false,
this.calculationMethod = 'UNIT',
this.baseUnit = 'pcs',
this.dailyRate,
});
factory ProductCategory.fromJson(Map<String, dynamic> json) {
return ProductCategory(
id: json['id'],
userId: json['userId'],
name: json['name'],
parentCategoryId: json['parentCategoryId'],
isCommodity: json['isCommodity'] ?? false,
calculationMethod: json['calculationMethod'] ?? 'UNIT',
baseUnit: json['baseUnit'] ?? 'pcs',
dailyRate: (json['dailyRate'] as num?)?.toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'name': name,
'parentCategoryId': parentCategoryId,
'isCommodity': isCommodity,
'calculationMethod': calculationMethod,
'baseUnit': baseUnit,
'dailyRate': dailyRate,
};
}
}
class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
@override
FutureOr<List<ProductCategory>> build() async {
return _fetchCategories();
}
Future<List<ProductCategory>> _fetchCategories() async {
final response = await DioClient().dio.get('/inventory/categories');
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
return data.map((e) => ProductCategory.fromJson(e)).toList();
}
return [];
}
Future<void> createCategory(ProductCategory category) async {
try {
await DioClient().dio.post(
'/inventory/categories',
data: category.toJson(),
);
state = AsyncValue.data(await _fetchCategories());
} catch (e) {
rethrow;
}
}
}
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
return ProductCategoriesNotifier();
});

View File

@@ -0,0 +1,123 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:io';
import '../../../core/network/dio_client.dart';
import '../domain/product.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
class ProductsNotifier extends AsyncNotifier<List<Product>> {
int _currentPage = 0;
bool _hasMore = true;
bool _isLoadingMore = false;
final int _pageSize = 20;
@override
FutureOr<List<Product>> build() async {
_currentPage = 0;
_hasMore = true;
return _fetchProducts(page: _currentPage, size: _pageSize);
}
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
final response = await DioClient().dio.get(
'/inventory/products',
queryParameters: {'page': page, 'size': size}
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data;
final products = data.map((e) => Product.fromJson(e)).toList();
if (products.length < size) {
_hasMore = false;
}
return products;
}
return [];
}
Future<void> fetchNextPage() async {
if (!_hasMore || _isLoadingMore || state.isLoading) return;
_isLoadingMore = true;
try {
final currentList = state.value ?? [];
final nextPage = _currentPage + 1;
final newProducts = await _fetchProducts(page: nextPage, size: _pageSize);
_currentPage = nextPage;
state = AsyncValue.data([...currentList, ...newProducts]);
} catch (e, stack) {
// Don't override state with error, just keep the current list, but maybe show a toast.
} finally {
_isLoadingMore = false;
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
_currentPage = 0;
_hasMore = true;
try {
state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize));
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> createProduct(Product product, {List<XFile>? images}) async {
try {
final response = await DioClient().dio.post(
'/inventory/products',
data: product.toJson(),
);
if (images != null && images.isNotEmpty && response.data != null) {
final productId = response.data['id'];
for (var image in images) {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(image.path, filename: image.name),
});
await DioClient().dio.post(
'/inventory/products/$productId/images',
data: formData,
);
}
}
// Refresh list
await refresh();
} catch (e) {
rethrow;
}
}
Future<void> updateProduct(int id, Product product, {List<XFile>? newImages}) async {
try {
await DioClient().dio.put(
'/inventory/products/$id',
data: product.toJson(),
);
if (newImages != null && newImages.isNotEmpty) {
for (var image in newImages) {
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(image.path, filename: image.name),
});
await DioClient().dio.post(
'/inventory/products/$id/images',
data: formData,
);
}
}
// Refresh list
await refresh();
} catch (e) {
rethrow;
}
}
}
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
return ProductsNotifier();
});