Inventory Management | Customer Management | Invoice Management Done | Commodity Rate Sync Done
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../../core/widgets/smart_search_dropdown.dart';
|
||||
import '../domain/product.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import '../providers/uoms_provider.dart';
|
||||
import '../domain/uom.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class AddProductScreen extends ConsumerStatefulWidget {
|
||||
final Product? product;
|
||||
@@ -28,6 +33,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
String _name = '';
|
||||
String _sku = '';
|
||||
ProductCategory? _selectedCategory;
|
||||
int? _uomId;
|
||||
|
||||
// Properties
|
||||
String _color = '';
|
||||
@@ -50,15 +56,22 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
// Media
|
||||
final List<XFile> _images = [];
|
||||
final List<int> _existingImageIds = [];
|
||||
bool _isSaving = false;
|
||||
String? _token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||
if (mounted) setState(() => _token = val);
|
||||
});
|
||||
|
||||
if (widget.product != null) {
|
||||
final p = widget.product!;
|
||||
_name = p.name;
|
||||
_sku = p.sku ?? '';
|
||||
_uomId = p.uomId;
|
||||
_color = p.color ?? '';
|
||||
_size = p.size ?? '';
|
||||
_dimensions = p.dimensions ?? '';
|
||||
@@ -73,6 +86,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
||||
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
||||
|
||||
if (p.imageIds.isNotEmpty) {
|
||||
_existingImageIds.addAll(p.imageIds);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cats = ref.read(productCategoriesProvider).value ?? [];
|
||||
if (cats.isNotEmpty) {
|
||||
@@ -105,7 +122,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
}
|
||||
|
||||
Future<void> _pickImages() async {
|
||||
if (_images.length >= 4) {
|
||||
if ((_images.length + _existingImageIds.length) >= 4) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +130,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final List<XFile> picked = await picker.pickMultiImage();
|
||||
if (picked.isNotEmpty) {
|
||||
setState(() {
|
||||
_images.addAll(picked.take(4 - _images.length));
|
||||
_images.addAll(picked.take(4 - (_images.length + _existingImageIds.length)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -124,6 +141,30 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _previewImage(int index) {
|
||||
List<ImageProvider> allImages = [];
|
||||
for (final imageId in _existingImageIds) {
|
||||
if (_token != null) {
|
||||
allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'}));
|
||||
} else {
|
||||
allImages.add(const AssetImage('assets/images/placeholder.png'));
|
||||
}
|
||||
}
|
||||
for (final file in _images) {
|
||||
allImages.add(FileImage(File(file.path)));
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: index,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddCategoryDialog() {
|
||||
String newCatName = '';
|
||||
bool newCatCommodity = false;
|
||||
@@ -156,10 +197,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
onChanged: (val) => setDialogState(() => newCatCommodity = val),
|
||||
),
|
||||
if (newCatCommodity) ...[
|
||||
_buildPremiumDropdown(
|
||||
label: 'Calculation Method',
|
||||
// Replace DropdownButtonFormField with SmartSearchDropdown
|
||||
SmartSearchDropdown<String>(
|
||||
hintText: 'Calculation Method',
|
||||
value: newCalcMethod,
|
||||
items: ['UNIT', 'WEIGHT', 'VOLUME'],
|
||||
items: const ['UNIT', 'WEIGHT', 'VOLUME'],
|
||||
itemAsString: (val) => val,
|
||||
onChanged: (val) => setDialogState(() {
|
||||
newCalcMethod = val!;
|
||||
if (val == 'WEIGHT') newBaseUnit = 'gm';
|
||||
@@ -224,15 +267,18 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final product = Product(
|
||||
id: widget.product?.id,
|
||||
name: _name,
|
||||
sku: _sku.isNotEmpty ? _sku : null,
|
||||
categoryId: _selectedCategory?.id,
|
||||
uomId: _uomId,
|
||||
color: _color.isNotEmpty ? _color : null,
|
||||
size: _size.isNotEmpty ? _size : null,
|
||||
dimensions: _dimensions.isNotEmpty ? _dimensions : null,
|
||||
purchasePrice: _purchasePrice,
|
||||
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
|
||||
gstRate: _gstRate,
|
||||
weight: _weight,
|
||||
color: _color,
|
||||
size: _size,
|
||||
dimensions: _dimensions,
|
||||
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
|
||||
autoCalculatePrice: _autoCalculatePrice,
|
||||
purityFactor: _purityFactor,
|
||||
@@ -258,9 +304,9 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
double _calculateLivePrice() {
|
||||
if (_selectedCategory == null || !_selectedCategory!.isCommodity) return _sellingPrice;
|
||||
double rate = _selectedCategory!.dailyRate ?? 0;
|
||||
double baseVal = (_selectedCategory!.calculationMethod == 'WEIGHT') ? _weight : 1.0;
|
||||
double baseVal = (_weight > 0) ? _weight : 1.0;
|
||||
|
||||
// Base Material Cost = (Weight + Wastage) * Rate * Purity
|
||||
// Base Material Cost = (Base Quantity + Wastage) * Rate * Purity
|
||||
double materialWeight = baseVal + (baseVal * (_wastagePercentage / 100));
|
||||
double materialCost = materialWeight * rate * _purityFactor;
|
||||
|
||||
@@ -431,11 +477,12 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPremiumDropdown<ProductCategory>(
|
||||
label: 'Select Category*',
|
||||
// Replace DropdownButtonFormField with SmartSearchDropdown
|
||||
SmartSearchDropdown<ProductCategory>(
|
||||
hintText: 'Select Category*',
|
||||
value: _selectedCategory,
|
||||
items: leafCategories,
|
||||
itemLabel: (c) {
|
||||
itemAsString: (c) {
|
||||
String displayName = c.name;
|
||||
if (c.parentCategoryId != null) {
|
||||
final parent = categories.firstWhere((p) => p.id == c.parentCategoryId, orElse: () => c);
|
||||
@@ -460,6 +507,31 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
const Text('Unit of Measure', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final uomsState = ref.watch(uomsProvider);
|
||||
return uomsState.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text('Error loading UOMs: $err'),
|
||||
data: (uoms) {
|
||||
return SmartSearchDropdown<int>(
|
||||
hintText: 'Select Unit of Measure (Optional)',
|
||||
value: _uomId,
|
||||
items: uoms.map((u) => u.id!).toList(),
|
||||
itemAsString: (id) {
|
||||
final uom = uoms.firstWhere((u) => u.id == id);
|
||||
return '${uom.name} (${uom.abbreviation})';
|
||||
},
|
||||
onChanged: (val) => setState(() => _uomId = val),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -477,11 +549,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
const SizedBox(height: 32),
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'Weight',
|
||||
label: _selectedCategory?.isCommodity == true ? 'Volume / Weight' : 'Weight',
|
||||
initialValue: _weight == 0 ? '' : _weight.toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
|
||||
suffixText: _selectedCategory?.baseUnit ?? 'unit',
|
||||
suffixText: _getUomAbbreviation() ?? _selectedCategory?.baseUnit ?? 'unit',
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildPremiumTextField(
|
||||
@@ -567,7 +639,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
flex: 1,
|
||||
child: _buildPremiumTextField(
|
||||
label: 'Making Charges',
|
||||
initialValue: _makingCharges.toString(),
|
||||
@@ -583,7 +655,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
label: 'Type',
|
||||
value: _makingChargesType,
|
||||
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
|
||||
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory!.baseUnit}' : t == 'PERCENTAGE' ? '%' : 'Flat',
|
||||
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory?.baseUnit ?? "Unit"}' : t == 'PERCENTAGE' ? 'Percentage' : 'Flat',
|
||||
onChanged: (val) => setState(() => _makingChargesType = val!),
|
||||
darkTheme: true,
|
||||
),
|
||||
@@ -666,7 +738,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (_images.isNotEmpty)
|
||||
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
@@ -676,46 +748,42 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: _images.length,
|
||||
itemCount: _images.length + _existingImageIds.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
color: Colors.grey[200],
|
||||
child: Image.file(
|
||||
File(_images[index].path),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () => _removeImage(index),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
if (index < _existingImageIds.length) {
|
||||
final imageId = _existingImageIds[index];
|
||||
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
|
||||
return _buildImageThumbnail(
|
||||
isNetwork: true,
|
||||
url: imageUrl,
|
||||
onTap: () => _previewImage(index),
|
||||
onDelete: () async {
|
||||
try {
|
||||
setState(() => _isSaving = true);
|
||||
await DioClient().dio.delete('/inventory/products/images/$imageId');
|
||||
setState(() => _existingImageIds.removeAt(index));
|
||||
// Update product list in background
|
||||
ref.read(productsProvider.notifier).refresh();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete image: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
final localIndex = index - _existingImageIds.length;
|
||||
return _buildImageThumbnail(
|
||||
isNetwork: false,
|
||||
file: File(_images[localIndex].path),
|
||||
onTap: () => _previewImage(index),
|
||||
onDelete: () => _removeImage(localIndex)
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_images.length < 4)
|
||||
if ((_images.length + _existingImageIds.length) < 4)
|
||||
GestureDetector(
|
||||
onTap: _pickImages,
|
||||
child: Container(
|
||||
@@ -731,7 +799,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
Text('${4 - _images.length} slots remaining', style: const TextStyle(color: Colors.grey)),
|
||||
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -741,6 +809,55 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
color: Colors.grey[200],
|
||||
child: isNetwork
|
||||
? (_token == null
|
||||
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
))
|
||||
: Image.file(
|
||||
file!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ==== WIDGET HELPERS ====
|
||||
|
||||
Widget _buildPremiumTextField({
|
||||
@@ -787,26 +904,50 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
required void Function(T?) onChanged,
|
||||
bool darkTheme = false,
|
||||
}) {
|
||||
return DropdownButtonFormField<T>(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
|
||||
filled: true,
|
||||
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
|
||||
),
|
||||
return Theme(
|
||||
data: darkTheme ? Theme.of(context).copyWith(
|
||||
textTheme: Theme.of(context).textTheme.apply(bodyColor: Colors.white, displayColor: Colors.white),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
labelStyle: const TextStyle(color: Colors.white70),
|
||||
filled: true,
|
||||
fillColor: Colors.black26,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Colors.white, width: 2)
|
||||
),
|
||||
)
|
||||
) : Theme.of(context).copyWith(
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
labelStyle: TextStyle(color: Colors.grey[600]),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||
),
|
||||
)
|
||||
),
|
||||
child: SmartSearchDropdown<T>(
|
||||
hintText: label,
|
||||
value: value,
|
||||
items: items,
|
||||
itemAsString: (e) => itemLabel != null ? itemLabel(e) : e.toString(),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
dropdownColor: darkTheme ? Colors.blue.shade900 : null,
|
||||
style: TextStyle(color: darkTheme ? Colors.white : Colors.black87, fontSize: 16),
|
||||
value: value,
|
||||
items: items.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(itemLabel != null ? itemLabel(e) : e.toString()),
|
||||
)).toList(),
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
|
||||
String? _getUomAbbreviation() {
|
||||
if (_uomId == null) return null;
|
||||
final uomsState = ref.read(uomsProvider).value;
|
||||
if (uomsState == null) return null;
|
||||
try {
|
||||
final uom = uomsState.firstWhere((u) => u.id == _uomId);
|
||||
return uom.abbreviation;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
191
kifi-app/lib/features/inventory/presentation/bom_tab.dart
Normal file
191
kifi-app/lib/features/inventory/presentation/bom_tab.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:kifi_app/core/widgets/smart_search_dropdown.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/product_bom.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/product.dart';
|
||||
|
||||
class BomTab extends ConsumerStatefulWidget {
|
||||
final int productId;
|
||||
|
||||
const BomTab({super.key, required this.productId});
|
||||
|
||||
@override
|
||||
ConsumerState<BomTab> createState() => _BomTabState();
|
||||
}
|
||||
|
||||
class _BomTabState extends ConsumerState<BomTab> {
|
||||
bool _isLoading = true;
|
||||
List<ProductBom> _bomItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBom();
|
||||
}
|
||||
|
||||
Future<void> _loadBom() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId);
|
||||
setState(() {
|
||||
_bomItems = items;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading BOM: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddComponentSheet() {
|
||||
final productsState = ref.read(productsProvider);
|
||||
final availableProducts = (productsState.value ?? []).where((p) => p.id != widget.productId).toList();
|
||||
|
||||
Product? selectedProduct;
|
||||
final qtyCtrl = TextEditingController(text: '1');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Add Component", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
SmartSearchDropdown<Product>(
|
||||
hintText: 'Select Product',
|
||||
value: selectedProduct,
|
||||
items: availableProducts,
|
||||
itemAsString: (p) => p.name,
|
||||
onChanged: (val) {
|
||||
setSheetState(() {
|
||||
selectedProduct = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: qtyCtrl,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quantity Required',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (selectedProduct == null) return;
|
||||
final qty = double.tryParse(qtyCtrl.text) ?? 1.0;
|
||||
|
||||
final bomItem = ProductBom(
|
||||
parentProductId: widget.productId,
|
||||
componentProductId: selectedProduct!.id!,
|
||||
quantity: qty,
|
||||
);
|
||||
|
||||
try {
|
||||
await ref.read(productsProvider.notifier).addBomItem(widget.productId, bomItem);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
_loadBom();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding component: $e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Add to BOM'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final allProducts = productsState.value ?? [];
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (_bomItems.isEmpty)
|
||||
const Center(child: Text("No components added yet.", style: TextStyle(color: Colors.grey)))
|
||||
else
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.all(16).copyWith(bottom: 80),
|
||||
itemCount: _bomItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _bomItems[index];
|
||||
final component = allProducts.firstWhere(
|
||||
(p) => p.id == item.componentProductId,
|
||||
orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'),
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
title: Text(component.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text('Quantity Required: ${item.quantity}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref.read(productsProvider.notifier).deleteBomItem(item.id!);
|
||||
_loadBom();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error deleting component: $e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 90,
|
||||
right: 16,
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'add_bom_btn',
|
||||
onPressed: _showAddComponentSheet,
|
||||
backgroundColor: Colors.indigo,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add Component"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import '../domain/category_rate_history.dart';
|
||||
|
||||
class DailyRatesScreen extends ConsumerStatefulWidget {
|
||||
const DailyRatesScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DailyRatesScreen> createState() => _DailyRatesScreenState();
|
||||
}
|
||||
|
||||
class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
|
||||
final Map<int, TextEditingController> _rateControllers = {};
|
||||
final Map<int, bool> _isExpanded = {};
|
||||
final Map<int, List<CategoryRateHistory>> _historyCache = {};
|
||||
final Map<int, bool> _isLoadingHistory = {};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (var controller in _rateControllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchHistory(int categoryId) async {
|
||||
setState(() => _isLoadingHistory[categoryId] = true);
|
||||
final historyData = await ref.read(productCategoriesProvider.notifier).fetchRateHistory(categoryId);
|
||||
setState(() {
|
||||
_historyCache[categoryId] = historyData.map((e) => CategoryRateHistory.fromJson(e)).toList();
|
||||
_isLoadingHistory[categoryId] = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleExpand(int categoryId) {
|
||||
setState(() {
|
||||
_isExpanded[categoryId] = !(_isExpanded[categoryId] ?? false);
|
||||
});
|
||||
if (_isExpanded[categoryId] == true && _historyCache[categoryId] == null) {
|
||||
_fetchHistory(categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveRate(int categoryId) async {
|
||||
final text = _rateControllers[categoryId]?.text;
|
||||
if (text == null || text.isEmpty) return;
|
||||
|
||||
final rate = double.tryParse(text);
|
||||
if (rate == null) return;
|
||||
|
||||
try {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
await ref.read(productCategoriesProvider.notifier).updateCategoryRate(categoryId, rate);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // close loading
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Rate updated successfully!'), backgroundColor: Colors.green),
|
||||
);
|
||||
_fetchHistory(categoryId); // refresh history
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error updating rate: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncRates(int categoryId) async {
|
||||
try {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
final count = await ref.read(productCategoriesProvider.notifier).syncRates(categoryId);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // close loading
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Successfully synced rates for $count products!'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error syncing rates: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: categoriesState.when(
|
||||
data: (categories) {
|
||||
final commodities = categories.where((c) => c.isCommodity).toList();
|
||||
|
||||
if (commodities.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(LucideIcons.boxes, size: 64, color: Colors.grey.shade300),
|
||||
const SizedBox(height: 16),
|
||||
Text('No Commodity Categories', style: TextStyle(fontSize: 18, color: Colors.grey.shade600)),
|
||||
const SizedBox(height: 8),
|
||||
Text('Mark a category as a commodity to manage its daily rates.', style: TextStyle(color: Colors.grey.shade500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: commodities.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = commodities[index];
|
||||
if (!_rateControllers.containsKey(category.id)) {
|
||||
_rateControllers[category.id!] = TextEditingController(text: category.dailyRate?.toString() ?? '');
|
||||
}
|
||||
final controller = _rateControllers[category.id]!;
|
||||
final isExpanded = _isExpanded[category.id] ?? false;
|
||||
final history = _historyCache[category.id];
|
||||
final isLoadingHistory = _isLoadingHistory[category.id] ?? false;
|
||||
|
||||
DateTime? lastSyncDate;
|
||||
if (history != null && history.isNotEmpty) {
|
||||
lastSyncDate = history.first.updatedAt;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Main Card Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(LucideIcons.trendingUp, color: Colors.blue.shade700, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.name,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (lastSyncDate != null)
|
||||
Text(
|
||||
'Last updated: ${DateFormat('MMM dd, yyyy HH:mm').format(lastSyncDate)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Base Unit: ${category.baseUnit}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isExpanded ? LucideIcons.chevronUp : LucideIcons.history, color: Colors.grey.shade600),
|
||||
onPressed: () => _toggleExpand(category.id!),
|
||||
tooltip: 'View History',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Today\'s Rate (per ${category.baseUnit})',
|
||||
prefixText: '₹ ',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _saveRate(category.id!),
|
||||
icon: const Icon(LucideIcons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _syncRates(category.id!),
|
||||
icon: Icon(LucideIcons.refreshCw, size: 18, color: Colors.blue.shade700),
|
||||
label: const Text('Sync Rate to All Products', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.blue.shade700,
|
||||
side: BorderSide(color: Colors.blue.shade700),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Expandable History Section
|
||||
if (isExpanded)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Rate History', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoadingHistory)
|
||||
const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
|
||||
else if (history == null || history.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('No history found.'),
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: history.length > 5 ? 5 : history.length, // Show up to 5 recent
|
||||
separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300),
|
||||
itemBuilder: (context, idx) {
|
||||
final item = history[idx];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(DateFormat('MMM dd, yyyy').format(item.date), style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
Text('₹ ${item.rate.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:kifi_app/core/network/dio_client.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/product.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/bom_tab.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
|
||||
|
||||
class ProductDetailScreen extends ConsumerWidget {
|
||||
final Product product;
|
||||
|
||||
const ProductDetailScreen({super.key, required this.product});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch products to get latest updates (like stock changes)
|
||||
final productsState = ref.watch(productsProvider);
|
||||
final currentProduct = productsState.value?.firstWhere((p) => p.id == product.id, orElse: () => product) ?? product;
|
||||
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.black),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AddProductScreen(product: currentProduct),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
labelColor: Colors.blue,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: Colors.blue,
|
||||
tabs: [
|
||||
Tab(text: "Overview"),
|
||||
Tab(text: "BOM"),
|
||||
Tab(text: "Stock Ledger"),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_buildOverviewTab(context, currentProduct, ref),
|
||||
BomTab(productId: currentProduct.id!),
|
||||
StockLedgerTab(productId: currentProduct.id!),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AdjustStockSheet(productId: currentProduct.id!),
|
||||
);
|
||||
},
|
||||
backgroundColor: Colors.blue,
|
||||
icon: const Icon(Icons.inventory),
|
||||
label: const Text("Adjust Stock"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image Header
|
||||
if (p.imageIds.isNotEmpty)
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FutureBuilder<String?>(
|
||||
future: DioClient().storage.read(key: 'jwt_token'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final token = snapshot.data;
|
||||
if (token == null) {
|
||||
return const Icon(Icons.image, size: 50, color: Colors.grey);
|
||||
}
|
||||
return Image.network(
|
||||
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(Icons.image, size: 50, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Stock Summary Card
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.inventory_2, color: Colors.blue),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Current Stock", style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||||
Text(
|
||||
"${p.currentStock ?? 0}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Details Card
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Product Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const Divider(),
|
||||
_buildDetailRow("SKU", p.sku ?? "-"),
|
||||
_buildDetailRow("Purchase Price", "₹${p.purchasePrice?.toStringAsFixed(2) ?? '0.00'}"),
|
||||
_buildDetailRow("Selling Price", "₹${p.sellingPrice?.toStringAsFixed(2) ?? '0.00'}"),
|
||||
_buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"),
|
||||
_buildDetailRow("Reorder Level", "${p.reorderLevel ?? 0}"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey[600])),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import 'add_product_screen.dart';
|
||||
import 'product_detail_screen.dart';
|
||||
import 'daily_rates_screen.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
@@ -17,7 +20,7 @@ class ProductListScreen extends ConsumerStatefulWidget {
|
||||
class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String _searchQuery = '';
|
||||
Timer? _debounce;
|
||||
String? _token;
|
||||
|
||||
@override
|
||||
@@ -36,6 +39,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -54,7 +58,18 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Products Catalog'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trendingUp),
|
||||
tooltip: 'Daily Commodity Rates',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const DailyRatesScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -73,8 +88,9 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_searchQuery = val.toLowerCase();
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
ref.read(productsProvider.notifier).search(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -84,12 +100,11 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (products) {
|
||||
final filtered = products.where((p) => p.name.toLowerCase().contains(_searchQuery) || (p.sku != null && p.sku!.toLowerCase().contains(_searchQuery))).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
if (products.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
|
||||
@@ -102,17 +117,15 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: filtered.length + 1, // +1 for loading indicator
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: products.length + 1, // +1 for loading indicator
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filtered.length) {
|
||||
// We reached the end of the filtered list, show a loader if we are loading more
|
||||
// The notifier state doesn't expose _isLoadingMore cleanly without another property,
|
||||
// but if we are at the end, we can just return a tiny spacer.
|
||||
if (index == products.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
|
||||
final p = filtered[index];
|
||||
final p = products[index];
|
||||
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category';
|
||||
|
||||
return Card(
|
||||
@@ -122,7 +135,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => AddProductScreen(product: p)));
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p)));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
@@ -162,7 +175,7 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
p.trackInventory ? 'Stock: 0' : 'Untracked', // We'll link stock later
|
||||
p.trackInventory ? 'Stock: ${p.currentStock ?? 0}' : 'Untracked',
|
||||
style: TextStyle(
|
||||
color: p.trackInventory ? Colors.orange : Colors.grey,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
|
||||
import 'package:kifi_app/core/network/dio_client.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class QuickAdjustStockScreen extends ConsumerStatefulWidget {
|
||||
const QuickAdjustStockScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QuickAdjustStockScreen> createState() => _QuickAdjustStockScreenState();
|
||||
}
|
||||
|
||||
class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
Timer? _debounce;
|
||||
String? _token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_loadToken();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(productsProvider.notifier).fetchNextPage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadToken() async {
|
||||
final token = await const FlutterSecureStorage().read(key: 'jwt_token');
|
||||
if (mounted) {
|
||||
setState(() => _token = token);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsState = ref.watch(productsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Quick Adjust Stock'),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search product to adjust...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
ref.read(productsProvider.notifier).search(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: productsState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
data: (products) {
|
||||
if (products.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(child: Text('No products found.')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: products.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == products.length) {
|
||||
return const SizedBox(height: 80);
|
||||
}
|
||||
final p = products[index];
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
leading: _buildProductImage(p),
|
||||
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(p.sku ?? 'No SKU', style: TextStyle(color: Colors.grey.shade600)),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${p.currentStock ?? 0}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.blue),
|
||||
),
|
||||
const Text('in stock', style: TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AdjustStockSheet(productId: p.id!),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductImage(product) {
|
||||
if (product.imageIds.isEmpty) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(LucideIcons.package, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
color: Colors.grey[200],
|
||||
child: _token == null
|
||||
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||
: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class StockLedgerTab extends ConsumerStatefulWidget {
|
||||
final int productId;
|
||||
|
||||
const StockLedgerTab({super.key, required this.productId});
|
||||
|
||||
@override
|
||||
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
|
||||
}
|
||||
|
||||
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
|
||||
List<StockMovement>? _ledger;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchLedger();
|
||||
}
|
||||
|
||||
Future<void> _fetchLedger() async {
|
||||
try {
|
||||
final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_ledger = ledger;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_ledger == null || _ledger!.isEmpty) {
|
||||
return const Center(child: Text("No stock movements recorded."));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _fetchLedger,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _ledger!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final movement = _ledger![index];
|
||||
final isAddition = movement.type == 'ADDITION' || movement.type == 'OPENING';
|
||||
final qty = movement.items?.isNotEmpty == true ? movement.items!.first.quantity : 0.0;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isAddition ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isAddition ? Icons.arrow_downward : Icons.arrow_upward,
|
||||
color: isAddition ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
title: Text(movement.type, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(
|
||||
movement.createdAt != null ? DateFormat('dd MMM yyyy, hh:mm a').format(movement.createdAt!) : '',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: isAddition ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
if (movement.notes != null && movement.notes!.isNotEmpty)
|
||||
Text(
|
||||
movement.notes!,
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../providers/uoms_provider.dart';
|
||||
import 'widgets/add_uom_sheet.dart';
|
||||
|
||||
class UomsListScreen extends ConsumerStatefulWidget {
|
||||
const UomsListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<UomsListScreen> createState() => _UomsListScreenState();
|
||||
}
|
||||
|
||||
class _UomsListScreenState extends ConsumerState<UomsListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uomsState = ref.watch(uomsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Units of Measure'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search units of measure...',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 500), () {
|
||||
ref.read(uomsProvider.notifier).search(val);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: uomsState.when(
|
||||
data: (uoms) {
|
||||
if (uoms.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
const SizedBox(height: 100),
|
||||
Icon(LucideIcons.ruler, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Center(child: Text('No Units of Measure found', style: TextStyle(color: Colors.grey[600], fontSize: 16))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: uoms.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final uom = uoms[index];
|
||||
return Dismissible(
|
||||
key: ValueKey(uom.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(LucideIcons.trash2, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete UOM'),
|
||||
content: Text('Are you sure you want to delete ${uom.name}?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onDismissed: (direction) {
|
||||
ref.read(uomsProvider.notifier).deleteUom(uom.id!);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
title: Text(uom.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text('Abbreviation: ${uom.abbreviation}'),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(LucideIcons.chevronRight, size: 20),
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => AddUomSheet(uom: uom),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Error: $e', style: const TextStyle(color: Colors.red)),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.read(uomsProvider.notifier).fetchUoms(),
|
||||
child: const Text('Retry'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => const AddUomSheet(),
|
||||
);
|
||||
},
|
||||
child: const Icon(LucideIcons.plus),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/uom.dart';
|
||||
import '../../providers/uoms_provider.dart';
|
||||
|
||||
class AddUomSheet extends ConsumerStatefulWidget {
|
||||
final UnitOfMeasure? uom;
|
||||
|
||||
const AddUomSheet({super.key, this.uom});
|
||||
|
||||
@override
|
||||
ConsumerState<AddUomSheet> createState() => _AddUomSheetState();
|
||||
}
|
||||
|
||||
class _AddUomSheetState extends ConsumerState<AddUomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _abbrevController;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.uom?.name ?? '');
|
||||
_abbrevController = TextEditingController(text: widget.uom?.abbreviation ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_abbrevController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final uom = UnitOfMeasure(
|
||||
id: widget.uom?.id,
|
||||
name: _nameController.text.trim(),
|
||||
abbreviation: _abbrevController.text.trim(),
|
||||
);
|
||||
|
||||
if (widget.uom == null) {
|
||||
await ref.read(uomsProvider.notifier).createUom(uom);
|
||||
} else {
|
||||
await ref.read(uomsProvider.notifier).updateUom(widget.uom!.id!, uom);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(widget.uom == null ? 'Unit of Measure created' : 'Unit of Measure updated')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
top: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(widget.uom == null ? "Add Unit of Measure" : "Edit Unit of Measure", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Name (e.g., Kilogram)",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100]
|
||||
),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Please enter a name' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _abbrevController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Abbreviation (e.g., kg)",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100]
|
||||
),
|
||||
validator: (val) => val == null || val.isEmpty ? 'Please enter an abbreviation' : null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(widget.uom == null ? "Save UOM" : "Update UOM", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:kifi_app/features/inventory/domain/stock_movement.dart';
|
||||
import 'package:kifi_app/features/inventory/providers/products_provider.dart';
|
||||
|
||||
class AdjustStockSheet extends ConsumerStatefulWidget {
|
||||
final int productId;
|
||||
|
||||
const AdjustStockSheet({super.key, required this.productId});
|
||||
|
||||
@override
|
||||
ConsumerState<AdjustStockSheet> createState() => _AdjustStockSheetState();
|
||||
}
|
||||
|
||||
class _AdjustStockSheetState extends ConsumerState<AdjustStockSheet> {
|
||||
String _selectedType = 'ADDITION';
|
||||
final _qtyController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
final List<String> _types = ['ADDITION', 'REDUCTION', 'DAMAGE', 'ADJUSTMENT'];
|
||||
|
||||
Future<void> _submit() async {
|
||||
final qtyText = _qtyController.text.trim();
|
||||
if (qtyText.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter a quantity')));
|
||||
return;
|
||||
}
|
||||
|
||||
final qty = double.tryParse(qtyText);
|
||||
if (qty == null || qty <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Quantity must be greater than 0')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final movement = StockMovement(
|
||||
type: _selectedType,
|
||||
notes: _notesController.text.trim(),
|
||||
items: [
|
||||
StockMovementItem(quantity: qty)
|
||||
]
|
||||
);
|
||||
|
||||
await ref.read(productsProvider.notifier).adjustStock(widget.productId, movement);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Stock adjusted successfully')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
top: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("Adjust Stock", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedType,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Movement Type",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
items: _types.map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedType = val);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _qtyController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: "Quantity",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
prefixIcon: const Icon(Icons.inventory_2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _notesController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Notes (Optional)",
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
prefixIcon: const Icon(Icons.note),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text("Save Adjustment", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user