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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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