Product Add Done
This commit is contained in:
@@ -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),
|
||||
)),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal file
61
kifi-app/lib/features/business/domain/business_profile.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
120
kifi-app/lib/features/inventory/domain/product.dart
Normal file
120
kifi-app/lib/features/inventory/domain/product.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal file
123
kifi-app/lib/features/inventory/providers/products_provider.dart
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user