Done Category, Product Catalogue, Customer, Vendor, Purchase Invoice Module

This commit is contained in:
2026-08-28 11:30:18 +05:30
parent 0d13833679
commit 189f49ed07
118 changed files with 12624 additions and 4004 deletions

View File

@@ -31,7 +31,8 @@ class CategoryRateHistory {
'id': id,
'categoryId': categoryId,
'rate': rate,
'date': "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}",
'date':
"${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}",
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};

View File

@@ -22,6 +22,7 @@ class InventoryItem {
final int? branchId;
final String? purchaseRef;
final String? status;
final DateTime? createdAt;
InventoryItem({
this.id,
@@ -47,6 +48,7 @@ class InventoryItem {
this.branchId,
this.purchaseRef,
this.status,
this.createdAt,
});
factory InventoryItem.fromJson(Map<String, dynamic> json) {
@@ -74,6 +76,7 @@ class InventoryItem {
branchId: json['branchId'],
purchaseRef: json['purchaseRef'],
status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
);
}
}

View File

@@ -9,13 +9,9 @@ class Product {
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;
@@ -24,7 +20,6 @@ class Product {
final double? makingCharges;
final String? makingChargesType;
final double? wastagePercentage;
final bool trackInventory;
final bool isActive;
final List<int> imageIds;
final double? currentStock;
@@ -39,13 +34,9 @@ class Product {
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',
@@ -54,7 +45,6 @@ class Product {
this.makingCharges = 0.0,
this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0,
this.trackInventory = true,
this.isActive = true,
this.imageIds = const [],
this.currentStock = 0.0,
@@ -71,27 +61,36 @@ class Product {
sku: json['sku'],
barcode: json['barcode'],
description: json['description'],
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(),
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] 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'] ?? 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,
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,
isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null
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,
currentStock:
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
0.0,
);
}
@@ -106,13 +105,9 @@ class Product {
'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,
@@ -121,7 +116,6 @@ class Product {
'makingCharges': makingCharges,
'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage,
'trackInventory': trackInventory,
'isActive': isActive,
};
}

View File

@@ -4,7 +4,7 @@ class ProductBom {
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;
@@ -23,7 +23,9 @@ class ProductBom {
parentProductId: json['parentProductId'],
componentProductId: json['componentProductId'],
quantity: (json['quantity'] as num).toDouble(),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
componentProduct: json['componentProduct'],
);
}

View File

@@ -5,12 +5,22 @@ class StockMovementItem {
final double quantity;
final double? unitPrice;
final String? huid;
final double? weight;
final String? photoUrl;
final double? purchaseAmount;
StockMovementItem({
this.id,
this.movementId,
this.productId,
required this.quantity,
this.unitPrice,
this.huid,
this.weight,
this.photoUrl,
this.purchaseAmount,
});
factory StockMovementItem.fromJson(Map<String, dynamic> json) {
@@ -20,6 +30,11 @@ class StockMovementItem {
productId: json['productId'],
quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unitPrice'] as num?)?.toDouble(),
huid: json['huid'],
weight: json['weight'] != null ? (json['weight'] as num).toDouble() : null,
photoUrl: json['photoUrl'],
purchaseAmount: json['purchaseAmount'] != null ? (json['purchaseAmount'] as num).toDouble() : null,
);
}
@@ -30,6 +45,11 @@ class StockMovementItem {
'productId': productId,
'quantity': quantity,
'unitPrice': unitPrice,
'huid': huid,
'weight': weight,
'photoUrl': photoUrl,
'purchaseAmount': purchaseAmount,
};
}
}
@@ -38,7 +58,8 @@ class StockMovement {
final int? id;
final int? userId;
final int? locationId;
final String type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN
final String
type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN
final int? referenceTransactionId;
final String? notes;
final DateTime? createdAt;
@@ -63,9 +84,13 @@ class StockMovement {
type: json['type'],
referenceTransactionId: json['referenceTransactionId'],
notes: json['notes'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
items: json['items'] != null
? (json['items'] as List).map((i) => StockMovementItem.fromJson(i)).toList()
? (json['items'] as List)
.map((i) => StockMovementItem.fromJson(i))
.toList()
: null,
);
}

View File

@@ -1,39 +0,0 @@
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

@@ -3,15 +3,12 @@ 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';
@@ -24,36 +21,18 @@ class AddProductScreen extends ConsumerStatefulWidget {
}
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final int _totalPages = 4;
final _formKey = GlobalKey<FormState>();
final _hsnController = TextEditingController();
final _gstController = TextEditingController();
// Basic
String _name = '';
String _sku = '';
String _hsnCode = '';
ProductCategory? _selectedCategory;
int? _uomId;
// 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 = [];
@@ -72,21 +51,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final p = widget.product!;
_name = p.name;
_sku = p.sku ?? '';
_hsnCode = p.hsnCode ?? '';
_hsnController.text = p.hsnCode ?? '';
_uomId = p.uomId;
_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;
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds);
@@ -103,24 +71,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
}
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);
}
@override
void dispose() {
_hsnController.dispose();
_gstController.dispose();
super.dispose();
}
Future<void> _pickImages() async {
@@ -167,7 +122,6 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
);
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save();
@@ -182,24 +136,14 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final product = Product(
id: widget.product?.id,
name: _name,
hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null,
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null,
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,
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
autoCalculatePrice: _autoCalculatePrice,
purityFactor: _purityFactor,
makingCharges: _makingCharges,
makingChargesType: _makingChargesType,
wastagePercentage: _wastagePercentage,
trackInventory: _trackInventory,
gstRate: double.tryParse(_gstController.text) ?? 0,
priceCalcRule: 'MANUAL',
autoCalculatePrice: false,
);
if (widget.product != null) {
@@ -215,12 +159,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
}
}
double _calculateLivePrice() {
return _sellingPrice;
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
@@ -232,23 +174,185 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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(),
],
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'Product Name*',
initialValue: _name,
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onChanged: (val) => _name = val,
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'SKU / Barcode',
initialValue: _sku,
onChanged: (val) => _sku = val,
),
const SizedBox(height: 24),
const Text('Category & Unit', 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 leafCategories.isEmpty
? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))
: SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemAsString: (c) => c.name,
itemBuilder: (context, item) {
List<String> path = [];
ProductCategory? current = item;
while (current != null) {
path.insert(0, current.name);
if (current.parentCategoryId != null) {
current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull;
} else {
current = null;
}
}
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
);
},
onChanged: (val) {
setState(() {
_selectedCategory = val;
if (val != null) {
if (val.defaultHsn != null && val.defaultHsn!.isNotEmpty) {
_hsnController.text = val.defaultHsn!;
}
if (val.defaultGst != null && val.defaultGst! > 0) {
_gstController.text = val.defaultGst.toString();
}
}
});
},
);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: const InputDecoration(labelText: 'Unit of Measure (Optional)'),
value: _uomId,
items: const [
DropdownMenuItem(value: 1, child: Text('Grams (g)')),
DropdownMenuItem(value: 2, child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 3, child: Text('Pieces (pcs)')),
],
onChanged: (val) => setState(() => _uomId = val),
),
const SizedBox(height: 24),
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _hsnController,
decoration: const InputDecoration(labelText: 'HSN Code'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _gstController,
decoration: const InputDecoration(labelText: 'GST Rate (%)', suffixText: '%'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 24),
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1,
),
itemCount: _images.length + _existingImageIds.length,
itemBuilder: (context, index) {
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));
ref.invalidate(productsProvider);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $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),
);
}
},
),
if ((_images.length + _existingImageIds.length) < 4) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: _pickImages,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(LucideIcons.uploadCloud, size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 8),
const Text('Tap to Upload Images', style: TextStyle(fontWeight: FontWeight.bold)),
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
),
]
],
),
),
),
),
@@ -259,476 +363,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
);
}
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)
),
),
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSaving ? null : _save,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
),
],
child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.product != null ? 'Update Product' : 'Publish Product', 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. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))),
],
),
)
else
SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemAsString: (c) => c.name,
itemBuilder: (context, item) {
// Build full path
List<String> path = [];
ProductCategory? current = item;
while (current != null) {
path.insert(0, current.name);
if (current.parentCategoryId != null) {
current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull;
} else {
current = null;
}
}
final pathString = path.join(' -> ');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text(pathString, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
);
},
onChanged: (val) => setState(() => _selectedCategory = val),
),
],
);
},
),
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),
);
},
);
},
),
],
),
);
}
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: _getUomAbbreviation() ?? _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 = 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),
),
],
),
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: 1,
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 ?? "Unit"}' : t == 'PERCENTAGE' ? '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),
],
Row(
children: [
Expanded(
child: _buildPremiumTextField(
label: 'HSN Code',
initialValue: _hsnCode,
onChanged: (val) => _hsnCode = val,
),
),
const SizedBox(width: 16),
Expanded(
child: _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 || _existingImageIds.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: _images.length + _existingImageIds.length,
itemBuilder: (context, index) {
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 + _existingImageIds.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 + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey)),
],
),
),
),
],
),
);
}
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
return Stack(
fit: StackFit.expand,
@@ -737,33 +393,29 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(16),
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),
borderRadius: BorderRadius.circular(16),
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,
),
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,
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: onDelete,
@@ -778,10 +430,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
);
}
// ==== WIDGET HELPERS ====
Widget _buildPremiumTextField({
required String label,
required String label,
String? initialValue,
String? prefixText,
String? suffixText,
@@ -789,25 +439,13 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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,
@@ -815,59 +453,4 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
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 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,
),
);
}
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

@@ -27,13 +27,17 @@ class _BomTabState extends ConsumerState<BomTab> {
Future<void> _loadBom() async {
setState(() => _isLoading = true);
try {
final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId);
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')));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error loading BOM: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
@@ -42,7 +46,9 @@ class _BomTabState extends ConsumerState<BomTab> {
void _showAddComponentSheet() {
final productsState = ref.read(productsProvider);
final availableProducts = (productsState.value ?? []).where((p) => p.id != widget.productId).toList();
final availableProducts = (productsState.value ?? [])
.where((p) => p.id != widget.productId)
.toList();
Product? selectedProduct;
final qtyCtrl = TextEditingController(text: '1');
@@ -63,7 +69,10 @@ class _BomTabState extends ConsumerState<BomTab> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Add Component", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const Text(
"Add Component",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
SmartSearchDropdown<Product>(
hintText: 'Select Product',
@@ -79,10 +88,11 @@ class _BomTabState extends ConsumerState<BomTab> {
const SizedBox(height: 16),
TextField(
controller: qtyCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Quantity Required',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
@@ -92,20 +102,26 @@ class _BomTabState extends ConsumerState<BomTab> {
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);
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')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error adding component: $e'),
),
);
}
}
},
@@ -121,7 +137,7 @@ class _BomTabState extends ConsumerState<BomTab> {
],
),
);
}
},
),
);
}
@@ -131,14 +147,19 @@ class _BomTabState extends ConsumerState<BomTab> {
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)))
const Center(
child: Text(
"No components added yet.",
style: TextStyle(color: Colors.grey),
),
)
else
ListView.builder(
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
@@ -146,25 +167,37 @@ class _BomTabState extends ConsumerState<BomTab> {
itemBuilder: (context, index) {
final item = _bomItems[index];
final component = allProducts.firstWhere(
(p) => p.id == item.componentProductId,
orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'),
(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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: Text(component.name, style: const TextStyle(fontWeight: FontWeight.bold)),
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!);
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')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error deleting component: $e'),
),
);
}
}
},
@@ -173,7 +206,7 @@ class _BomTabState extends ConsumerState<BomTab> {
);
},
),
Positioned(
bottom: 90,
right: 16,

View File

@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.dart';
import '../../../core/theme/nature_colors.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'dart:ui';
class CategoryManagementScreen extends ConsumerStatefulWidget {
const CategoryManagementScreen({super.key});
@@ -13,93 +18,575 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('Category Management'),
title: const Text('Category Management', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
),
body: categoriesAsync.when(
data: (categories) {
// Build category tree
final rootCategories = categories.where((c) => c.parentCategoryId == null).toList();
return ListView.builder(
itemCount: rootCategories.length,
itemBuilder: (context, index) {
return _buildCategoryTile(rootCategories[index], categories, 0);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddCategoryDialog(null),
child: const Icon(Icons.add),
),
);
}
Widget _buildCategoryTile(ProductCategory category, List<ProductCategory> allCategories, int depth) {
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
if (children.isEmpty) {
return ListTile(
contentPadding: EdgeInsets.only(left: 16.0 + (depth * 24.0), right: 16.0),
title: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddCategoryDialog(category),
),
);
}
return ExpansionTile(
tilePadding: EdgeInsets.only(left: 16.0 + (depth * 24.0), right: 16.0),
title: Text(category.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
body: Column(
children: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddCategoryDialog(category),
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Search categories...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
Expanded(
child: categoriesAsync.when(
data: (categories) {
Widget content;
if (categories.isEmpty) {
content = _buildEmptyState();
} else {
final rootCategories = categories.where((c) => c.parentCategoryId == null).toList();
content = ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: rootCategories.length,
itemBuilder: (context, index) {
return _buildCategoryNode(rootCategories[index], categories, 0, isDark);
},
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(productCategoriesProvider),
child: content,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
),
const Icon(Icons.expand_more),
],
),
children: children.map((child) => _buildCategoryTile(child, allCategories, depth + 1)).toList(),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddCategorySheet(context, null, ref),
icon: const Icon(LucideIcons.plus),
label: const Text('Add Category'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
);
}
void _showAddCategoryDialog(ProductCategory? parent) {
final nameController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(parent == null ? 'Add Root Category' : 'Add Subcategory to ${parent.name}'),
content: TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Category Name'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
Widget _buildEmptyState() {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 150),
Icon(LucideIcons.layers, size: 64, color: Colors.grey.withOpacity(0.5)),
const SizedBox(height: 16),
const Center(child: Text('No categories yet', style: TextStyle(fontSize: 18, color: Colors.grey))),
const SizedBox(height: 8),
const Center(child: Text('Create your first product category to get started', style: TextStyle(color: Colors.grey))),
],
);
}
Widget _buildCategoryNode(ProductCategory category, List<ProductCategory> allCategories, int depth, bool isDark) {
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
final isLeaf = children.isEmpty && !category.hasChild;
// A nice frosted glass card
return Container(
margin: EdgeInsets.only(left: depth * 16.0, bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor.withOpacity(isDark ? 0.4 : 0.8),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLeaf ? Colors.blue.withOpacity(0.3) : Colors.white.withOpacity(isDark ? 0.05 : 0.5),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.2 : 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
)
],
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: isLeaf
? ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.tag, size: 20, color: Colors.blue),
),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: category.commodityCode != null
? Text('${category.commodityCode} • HSN: ${category.defaultHsn ?? "N/A"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600))
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (category.defaultGst != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text('GST ${category.defaultGst}%',
style: const TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold)),
),
_buildCategoryPopupMenu(category, isLeaf),
],
),
)
: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(LucideIcons.folder, size: 20, color: AppTheme.primaryColor),
),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildCategoryPopupMenu(category, isLeaf),
const Icon(LucideIcons.chevronDown, size: 20, color: Colors.grey),
],
),
children: children.map((child) => _buildCategoryNode(child, allCategories, depth + 1, isDark)).toList(),
),
),
),
FilledButton(
onPressed: () {
if (nameController.text.isNotEmpty) {
final newCategory = ProductCategory(
name: nameController.text,
parentCategoryId: parent?.id,
);
ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
Navigator.pop(context);
}
},
child: const Text('Save'),
),
),
);
}
Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) {
return PopupMenuButton<String>(
icon: const Icon(LucideIcons.moreVertical, color: Colors.grey),
onSelected: (value) {
if (value == 'edit') {
_showAddCategorySheet(context, category, ref, isEdit: true);
} else if (value == 'add_sub') {
_showAddCategorySheet(context, category, ref, isEdit: false);
} else if (value == 'delete') {
if (category.id != null) {
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
}
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(children: [Icon(LucideIcons.edit, size: 18), SizedBox(width: 8), Text('Edit')]),
),
if (!isLeaf)
const PopupMenuItem(
value: 'add_sub',
child: Row(children: [Icon(LucideIcons.plus, size: 18), SizedBox(width: 8), Text('Add Subcategory')]),
),
const PopupMenuItem(
value: 'delete',
child: Row(children: [Icon(LucideIcons.trash2, size: 18, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red))]),
),
],
);
}
}
void _showAddCategorySheet(BuildContext context, ProductCategory? parentOrCategory, WidgetRef ref, {bool isEdit = false}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CategoryFormSheet(
parent: isEdit ? null : parentOrCategory,
categoryToEdit: isEdit ? parentOrCategory : null,
),
);
}
class CategoryFormSheet extends ConsumerStatefulWidget {
final ProductCategory? parent;
final ProductCategory? categoryToEdit;
const CategoryFormSheet({super.key, this.parent, this.categoryToEdit});
@override
ConsumerState<CategoryFormSheet> createState() => _CategoryFormSheetState();
}
class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _hsnController = TextEditingController();
final _gstController = TextEditingController();
final _makingChargeController = TextEditingController();
int? _selectedParentId;
bool _isLeaf = false;
bool _huidRequired = false;
String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'g';
final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'},
{'code': 'XAG', 'label': 'XAG - Silver'},
{'code': 'XPT', 'label': 'XPT - Platinum'},
{'code': 'XPD', 'label': 'XPD - Palladium'},
{'code': 'OTH', 'label': 'OTH - Other'},
];
@override
void initState() {
super.initState();
if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!;
_selectedParentId = c.parentCategoryId;
_nameController.text = c.name;
_isLeaf = !c.hasChild;
if (_isLeaf) {
_hsnController.text = c.defaultHsn ?? '';
_gstController.text = c.defaultGst?.toString() ?? '';
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
_huidRequired = c.huidRequired;
_commodityCode = c.commodityCode ?? 'XAU';
// Handle legacy or varying enum string values
_makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit ?? 'g';
if (_baseUnit == 'gm') _baseUnit = 'g';
}
} else if (widget.parent != null) {
_selectedParentId = widget.parent!.id;
}
}
@override
void dispose() {
_nameController.dispose();
_hsnController.dispose();
_gstController.dispose();
_makingChargeController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
final newCategory = ProductCategory(
id: widget.categoryToEdit?.id,
name: _nameController.text.trim(),
parentCategoryId: _selectedParentId,
hasChild: !_isLeaf,
commodityCode: _isLeaf ? _commodityCode : null,
defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
huidRequired: _isLeaf ? _huidRequired : false,
defaultMakingCharge: _isLeaf ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs',
);
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
ref.read(productCategoriesProvider.notifier).updateCategory(widget.categoryToEdit!.id!, newCategory);
} else {
ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
}
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
return Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 20, offset: const Offset(0, -5)),
],
),
child: Column(
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.categoryToEdit != null
? 'Edit Category'
: (widget.parent == null ? 'New Root Category' : 'Subcategory of ${widget.parent!.name}'),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Form
Expanded(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Builder(
builder: (context) {
final categoriesAsync = ref.watch(productCategoriesProvider);
final allCategories = categoriesAsync.value ?? [];
final parentOptions = allCategories.where((c) =>
c.hasChild && c.id != widget.categoryToEdit?.id
).toList();
return DropdownButtonFormField<int?>(
value: _selectedParentId,
decoration: InputDecoration(
labelText: 'Parent Category',
prefixIcon: const Icon(LucideIcons.folder),
),
items: [
const DropdownMenuItem(value: null, child: Text('None (Root Category)')),
...parentOptions.map((c) => DropdownMenuItem(
value: c.id,
child: Text(c.name),
)),
],
onChanged: (val) => setState(() => _selectedParentId = val),
);
},
),
const SizedBox(height: 24),
_buildTextField(
controller: _nameController,
label: 'Category Name',
icon: LucideIcons.tag,
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
),
const SizedBox(height: 24),
// Leaf Category Toggle
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? Colors.grey.shade900 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isLeaf ? Colors.blue.withOpacity(0.1) : AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_isLeaf ? LucideIcons.checkCircle : LucideIcons.folder,
color: _isLeaf ? Colors.blue : AppTheme.primaryColor),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Final Product Category (Leaf)', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 4),
Text('Enable this if actual products will be created under this category. Subcategories cannot be added to a leaf.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
),
Switch(
value: _isLeaf,
onChanged: (val) => setState(() => _isLeaf = val),
activeColor: Colors.blue,
),
],
),
),
if (_isLeaf) ...[
const SizedBox(height: 24),
const Text('Product Defaults', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (isBusinessMode) ...[
DropdownButtonFormField<String>(
value: _commodityCode,
decoration: InputDecoration(
labelText: 'Commodity Code',
prefixIcon: const Icon(LucideIcons.barChart2),
),
items: _commodities.map((c) => DropdownMenuItem(
value: c['code'],
child: Text(c['label']!),
)).toList(),
onChanged: (val) => setState(() => _commodityCode = val!),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
child: _buildTextField(
controller: _hsnController,
label: 'HSN Code',
icon: LucideIcons.hash,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTextField(
controller: _gstController,
label: 'Default GST %',
icon: LucideIcons.percent,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('HUID Required'),
subtitle: const Text('Is Hallmark Unique Identification mandatory?'),
value: _huidRequired,
onChanged: (val) => setState(() => _huidRequired = val),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: _makingChargeType,
decoration: InputDecoration(
labelText: 'Making Charge Type',
),
items: const [
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')),
DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage')),
],
onChanged: (val) => setState(() => _makingChargeType = val!),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTextField(
controller: _makingChargeController,
label: 'Default Charge',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _baseUnit,
decoration: InputDecoration(
labelText: 'Base Unit',
),
items: const [
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
],
onChanged: (val) => setState(() => _baseUnit = val!),
),
],
const SizedBox(height: 40),
],
),
),
),
// Footer
Padding(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 5,
shadowColor: AppTheme.primaryColor.withOpacity(0.5),
),
child: const Text('Save Category', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
),
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
IconData? icon,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: icon != null ? Icon(icon) : null,
),
);
}
}

View File

@@ -108,9 +108,9 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold(
backgroundColor: Colors.grey[50],
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
@@ -118,7 +118,7 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
),
body: categoriesState.when(
data: (categories) {
final commodities = categories.where((c) => c.isCommodity).toList();
final commodities = categories.where((c) => c.commodityCode != null && c.commodityCode!.isNotEmpty).toList();
if (commodities.isEmpty) {
return Center(
@@ -230,7 +230,6 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
decoration: InputDecoration(
labelText: 'Today\'s Rate (per ${category.baseUnit})',
prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),

View File

@@ -5,7 +5,6 @@ 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 {
@@ -17,17 +16,25 @@ class ProductDetailScreen extends ConsumerWidget {
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;
final currentProduct =
productsState.value?.firstWhere(
(p) => p.id == product.id,
orElse: () => product,
) ??
product;
return DefaultTabController(
length: 3,
length: 2,
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)),
title: Text(
currentProduct.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
actions: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.black),
@@ -35,7 +42,8 @@ class ProductDetailScreen extends ConsumerWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddProductScreen(product: currentProduct),
builder: (context) =>
AddProductScreen(product: currentProduct),
),
);
},
@@ -47,7 +55,6 @@ class ProductDetailScreen extends ConsumerWidget {
indicatorColor: Colors.blue,
tabs: [
Tab(text: "Overview"),
Tab(text: "BOM"),
Tab(text: "Stock Ledger"),
],
),
@@ -55,23 +62,9 @@ class ProductDetailScreen extends ConsumerWidget {
body: TabBarView(
children: [
_buildOverviewTab(context, currentProduct, ref),
BomTab(productId: currentProduct.id!),
StockLedgerTab(productId: currentProduct.id!),
StockLedgerTab(product: currentProduct),
],
),
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"),
),
),
);
}
@@ -100,68 +93,46 @@ class ProductDetailScreen extends ConsumerWidget {
}
final token = snapshot.data;
if (token == null) {
return const Icon(Icons.image, size: 50, color: Colors.grey);
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),
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
// Product Details Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
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 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}"),
_buildDetailRow("SKU / Barcode", p.sku ?? "-"),
_buildDetailRow("HSN Code", p.hsnCode ?? "-"),
_buildDetailRow(
"GST Rate",
"${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
),
],
),
),

View File

@@ -43,7 +43,8 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage();
}
}
@@ -52,126 +53,208 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
Widget build(BuildContext context) {
final productsState = ref.watch(productsProvider);
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar(
title: const Text('Products Catalog'),
title: const Text(
'Products Catalog',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
),
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) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(productsProvider.notifier).search(val);
});
},
children: [
Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search products by name or SKU...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
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: (err, stack) => Center(child: Text('Error: $err')),
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.', style: TextStyle(color: Colors.grey)))
],
),
);
}
),
Expanded(
child: productsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (products) {
if (products.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator
padding: const EdgeInsets.symmetric(horizontal: 16),
itemBuilder: (context, index) {
children: const [
SizedBox(height: 100),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.packageSearch,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No products found.',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
if (index == products.length) {
return const SizedBox(height: 80);
}
final p = products[index];
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
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: (_) => ProductDetailScreen(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)),
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ProductDetailScreen(product: p),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16),
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,
),
if (p.sku != null && p.sku!.isNotEmpty) ...[
const SizedBox(width: 8),
Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)),
]
],
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.currentStock != null)
? 'Stock: ${p.currentStock ?? 0}'
: 'Untracked',
style: TextStyle(
color: (p.currentStock != null)
? Colors.orange
: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
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: ${p.currentStock ?? 0}' : 'Untracked',
style: TextStyle(
color: p.trackInventory ? Colors.orange : Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold
),
)
],
),
],
],
),
),
),
),
@@ -181,12 +264,15 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
);
},
),
)
],
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen()));
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddProductScreen()),
);
},
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(LucideIcons.plus, color: Colors.white),
@@ -207,7 +293,8 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
);
}
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
final imageUrl =
'${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
return ClipRRect(
borderRadius: BorderRadius.circular(12),
@@ -215,15 +302,17 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
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),
),
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

@@ -11,10 +11,12 @@ class QuickAdjustStockScreen extends ConsumerStatefulWidget {
const QuickAdjustStockScreen({super.key});
@override
ConsumerState<QuickAdjustStockScreen> createState() => _QuickAdjustStockScreenState();
ConsumerState<QuickAdjustStockScreen> createState() =>
_QuickAdjustStockScreenState();
}
class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen> {
class _QuickAdjustStockScreenState
extends ConsumerState<QuickAdjustStockScreen> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _debounce;
@@ -28,7 +30,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage();
}
}
@@ -51,12 +54,9 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
@override
Widget build(BuildContext context) {
final productsState = ref.watch(productsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Quick Adjust Stock'),
elevation: 0,
),
appBar: AppBar(title: const Text('Quick Adjust Stock'), elevation: 0),
body: Column(
children: [
Padding(
@@ -66,13 +66,10 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
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,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
@@ -89,7 +86,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
data: (products) {
if (products.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
@@ -101,7 +99,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
}
return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
@@ -112,43 +111,60 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
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)),
],
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AdjustStockSheet(productId: p.id!),
);
},
),
);
},
),
);
},
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!),
);
},
),
);
},
),
);
},
),
),
],
@@ -169,7 +185,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
);
}
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
final imageUrl =
'${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
return ClipRRect(
borderRadius: BorderRadius.circular(12),
@@ -177,14 +194,15 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
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),
),
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

@@ -1,20 +1,26 @@
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/domain/inventory_item.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
class StockLedgerTab extends ConsumerStatefulWidget {
final int productId;
final Product product;
const StockLedgerTab({super.key, required this.productId});
const StockLedgerTab({super.key, required this.product});
@override
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
}
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<StockMovement>? _ledger;
List<InventoryItem>? _items;
bool _isLoading = true;
@override
@@ -25,10 +31,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Future<void> _fetchLedger() async {
try {
final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId);
final items = await ref
.read(productsProvider.notifier)
.fetchInventoryItems(widget.product.id!);
if (mounted) {
setState(() {
_ledger = ledger;
_items = items;
_isLoading = false;
});
}
@@ -37,7 +45,9 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
@@ -48,59 +58,225 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
return const Center(child: CircularProgressIndicator());
}
if (_ledger == null || _ledger!.isEmpty) {
return const Center(child: Text("No stock movements recorded."));
if (_items == null || _items!.isEmpty) {
return const Center(child: Text("No stock available."));
}
final categoriesState = ref.watch(productCategoriesProvider);
final category = categoriesState.value?.firstWhere(
(c) => c.id == widget.product.categoryId,
);
final String unit = category?.baseUnit ?? 'g';
final double currentRate = category?.dailyRate ?? 0.0;
return RefreshIndicator(
onRefresh: _fetchLedger,
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _ledger!.length,
itemCount: _items!.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;
final item = _items![index];
final weight = item.grossWeight ?? 0.0;
final purchaseRate = item.purchaseCost ?? 0.0;
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final isGain = gainLoss >= 0;
return 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,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: isAddition ? Colors.green : Colors.red,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer ${snapshot.data}'},
),
);
}
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.createdAt != null
? DateFormat('dd MMM yyyy').format(item.createdAt!)
: '',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'IN STOCK',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
const SizedBox(height: 4),
Text(
'${weight.toStringAsFixed(3)} $unit',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
if (movement.notes != null && movement.notes!.isNotEmpty)
Text(
movement.notes!,
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
],
),
),

View File

@@ -1,180 +0,0 @@
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

@@ -1,142 +0,0 @@
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,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/nature_colors.dart';
import '../../providers/product_categories_provider.dart';
class QuickRateUpdateSheet extends ConsumerStatefulWidget {
const QuickRateUpdateSheet({super.key});
@override
ConsumerState<QuickRateUpdateSheet> createState() => _QuickRateUpdateSheetState();
}
class _QuickRateUpdateSheetState extends ConsumerState<QuickRateUpdateSheet> {
String? _selectedCommodityCode;
final _rateController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_rateController.dispose();
super.dispose();
}
Future<void> _saveRate() async {
if (_selectedCommodityCode == null || _rateController.text.isEmpty) return;
setState(() => _isLoading = true);
try {
final rate = double.parse(_rateController.text);
await ref.read(productCategoriesProvider.notifier).updateCommodityRate(_selectedCommodityCode!, rate);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Rate updated successfully')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating rate: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
final commodityCodes = categoriesState.value
?.where((c) => c.commodityCode != null && c.commodityCode!.isNotEmpty)
.map((c) => c.commodityCode!)
.toSet()
.toList() ?? [];
if (_selectedCommodityCode == null && commodityCodes.isNotEmpty) {
_selectedCommodityCode = commodityCodes.first;
}
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
left: 24,
right: 24,
top: 12,
),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.bolt, color: Colors.orange, size: 28),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quick Rate Update', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
Text('Update live commodity prices', style: TextStyle(fontSize: 14, color: Colors.grey)),
],
),
),
],
),
const SizedBox(height: 32),
if (commodityCodes.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(width: 12),
Expanded(child: Text('No commodities found. Create a leaf category with a commodity code to update rates.', style: TextStyle(color: Colors.grey))),
],
),
)
else ...[
DropdownButtonFormField<String>(
value: _selectedCommodityCode,
decoration: InputDecoration(
labelText: 'Select Commodity',
labelStyle: const TextStyle(fontWeight: FontWeight.w500),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
icon: const Icon(Icons.unfold_more, color: Colors.grey),
items: commodityCodes.map((c) => DropdownMenuItem(value: c, child: Text(c, style: const TextStyle(fontWeight: FontWeight.w600)))).toList(),
onChanged: (val) => setState(() => _selectedCommodityCode = val),
),
const SizedBox(height: 16),
TextField(
controller: _rateController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
decoration: InputDecoration(
labelText: 'Base Rate (24k / Purity 1)',
labelStyle: const TextStyle(fontWeight: FontWeight.w500),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
prefixIcon: const Icon(Icons.currency_rupee, color: Colors.orange),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _saveRate,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5))
: const Text('Update Rate', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: 0.5)),
),
],
],
),
);
}
}

View File

@@ -8,15 +8,16 @@ class ProductCategory {
final String name;
final int? parentCategoryId;
final bool hasChild;
final String? commodityCode;
final String? defaultHsn;
final double? defaultGst;
final bool huidRequired;
final double? defaultMakingCharge;
final String? makingChargeType;
final String calculationMethod;
final String baseUnit;
final bool isActive;
final int sortOrder;
final double? dailyRate;
ProductCategory({
this.id,
@@ -24,15 +25,16 @@ class ProductCategory {
required this.name,
this.parentCategoryId,
this.hasChild = false,
this.commodityCode,
this.defaultHsn,
this.defaultGst,
this.huidRequired = false,
this.defaultMakingCharge,
this.makingChargeType,
this.calculationMethod = 'UNIT',
this.baseUnit = 'pcs',
this.isActive = true,
this.sortOrder = 0,
this.dailyRate,
});
factory ProductCategory.fromJson(Map<String, dynamic> json) {
@@ -42,15 +44,16 @@ class ProductCategory {
name: json['name'],
parentCategoryId: json['parentCategoryId'],
hasChild: json['hasChild'] ?? false,
commodityCode: json['commodityCode'],
defaultHsn: json['defaultHsn'],
defaultGst: (json['defaultGst'] as num?)?.toDouble(),
huidRequired: json['huidRequired'] ?? false,
defaultMakingCharge: (json['defaultMakingCharge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'],
calculationMethod: json['calculationMethod'] ?? 'UNIT',
baseUnit: json['baseUnit'] ?? 'pcs',
isActive: json['isActive'] ?? true,
sortOrder: json['sortOrder'] ?? 0,
dailyRate: (json['dailyRate'] as num?)?.toDouble(),
);
}
@@ -61,12 +64,12 @@ class ProductCategory {
'name': name,
'parentCategoryId': parentCategoryId,
'hasChild': hasChild,
'commodityCode': commodityCode,
'defaultHsn': defaultHsn,
'defaultGst': defaultGst,
'huidRequired': huidRequired,
'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType,
'calculationMethod': calculationMethod,
'baseUnit': baseUnit,
'isActive': isActive,
'sortOrder': sortOrder,
@@ -101,9 +104,71 @@ class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
}
}
Future<void> updateCategory(int id, ProductCategory category) async {
try {
await DioClient().dio.put(
'/inventory/categories/$id',
data: category.toJson(),
);
state = AsyncValue.data(await _fetchCategories());
} catch (e) {
rethrow;
}
}
Future<void> deleteCategory(int id) async {
try {
await DioClient().dio.delete('/inventory/categories/$id');
state = await AsyncValue.guard(() => _fetchCategories());
} catch (e) {
print('Error deleting category: $e');
rethrow;
}
}
Future<List<dynamic>> fetchRateHistory(int categoryId) async {
final response = await DioClient().dio.get(
'/inventory/categories/$categoryId/rates/history',
);
return response.data as List<dynamic>;
}
Future<void> updateCategoryRate(int categoryId, double rate) async {
await DioClient().dio.post(
'/inventory/categories/$categoryId/rates',
data: {'rate': rate},
);
state = await AsyncValue.guard(() => _fetchCategories());
}
Future<void> updateCommodityRate(String commodityCode, double rate) async {
final categories = state.value ?? [];
final matchingCategories = categories.where(
(c) => c.commodityCode == commodityCode,
);
for (var cat in matchingCategories) {
try {
await DioClient().dio.post(
'/inventory/categories/${cat.id}/rates',
data: {'rate': rate},
);
} catch (e) {
print('Error updating rate for category ${cat.id}: $e');
}
}
state = await AsyncValue.guard(() => _fetchCategories());
}
Future<int> syncRates(int categoryId) async {
final response = await DioClient().dio.post(
'/inventory/categories/$categoryId/rates/sync',
);
state = await AsyncValue.guard(() => _fetchCategories());
return response.data['updatedCount'] ?? 0;
}
}
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
return ProductCategoriesNotifier();
});
final productCategoriesProvider =
AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
return ProductCategoriesNotifier();
});

View File

@@ -6,6 +6,7 @@ import '../../../core/network/dio_client.dart';
import '../domain/product.dart';
import '../domain/stock_movement.dart';
import '../domain/product_bom.dart';
import '../domain/inventory_item.dart';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
@@ -24,20 +25,25 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
return _fetchProducts(page: _currentPage, size: _pageSize);
}
Future<List<Product>> _fetchProducts({required int page, required int size}) async {
Future<List<Product>> _fetchProducts({
required int page,
required int size,
}) async {
final response = await DioClient().dio.get(
'/inventory/products',
queryParameters: {
'page': page,
'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');
final file = File(
'/Users/maddy/Projects/Kifi/kifi-app/debug_product.json',
);
await file.writeAsString(jsonEncode(data.first));
} catch (_) {}
}
@@ -58,7 +64,7 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
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) {
@@ -73,7 +79,9 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
_currentPage = 0;
_hasMore = true;
try {
state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize));
state = AsyncValue.data(
await _fetchProducts(page: _currentPage, size: _pageSize),
);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
@@ -90,7 +98,7 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
'/inventory/products',
data: product.toJson(),
);
if (images != null && images.isNotEmpty && response.data != null) {
final productId = response.data['id'];
for (var image in images) {
@@ -100,9 +108,12 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
minWidth: 800,
quality: 80,
);
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
'file': MultipartFile.fromBytes(
compressedBytes,
filename: image.name,
),
});
await DioClient().dio.post(
'/inventory/products/$productId/images',
@@ -118,13 +129,17 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
}
}
Future<void> updateProduct(int id, Product product, {List<XFile>? newImages}) async {
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 bytes = await image.readAsBytes();
@@ -135,7 +150,10 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
);
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name),
'file': MultipartFile.fromBytes(
compressedBytes,
filename: image.name,
),
});
await DioClient().dio.post(
'/inventory/products/$id/images',
@@ -166,7 +184,9 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
Future<List<StockMovement>> fetchStockLedger(int productId) async {
try {
final response = await DioClient().dio.get('/inventory/products/$productId/movements');
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();
@@ -177,9 +197,26 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
}
}
Future<List<InventoryItem>> fetchInventoryItems(int productId) async {
try {
final response = await DioClient().dio.get(
'/inventory/items/product/$productId',
);
if (response.data != null) {
final List<dynamic> data = response.data;
return data.map((e) => InventoryItem.fromJson(e)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to fetch inventory items: $e');
}
}
Future<List<ProductBom>> fetchProductBom(int productId) async {
try {
final response = await DioClient().dio.get('/inventory/products/$productId/bom');
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();
@@ -210,6 +247,8 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
}
}
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() {
return ProductsNotifier();
});
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(
() {
return ProductsNotifier();
},
);

View File

@@ -1,96 +0,0 @@
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();
});