Done Category, Product Catalogue, Customer, Vendor, Purchase Invoice Module
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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'}%",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user