diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index fe5cca86..bd98855f 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -8,8 +8,11 @@ import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/category_then_product_picker.dart'; import '../../../core/ui/product_picker_field.dart'; +import '../../../core/ui/searchable_category_field.dart'; import '../../auth/data/auth_providers.dart'; +import '../../admin/domain/admin_category_node.dart'; import '../../pantry/data/pantry_providers.dart'; import '../data/inventory_providers.dart'; import '../../import/data/receipt_import_session.dart' show ImportDestination; @@ -34,7 +37,10 @@ class _CreateInventoryScreenState final _commentController = TextEditingController(); int? _selectedProductId; + int? _selectedCategoryId; List> _products = []; + List _categoryTree = []; + List _categoryOptions = []; bool _loadingProducts = false; DateTime? _purchaseDate; DateTime? _bestBeforeDate; @@ -48,7 +54,7 @@ class _CreateInventoryScreenState if (widget.initialDestination == 'pantry') { _destination = ImportDestination.pantry; } - _loadProducts(); + _loadProductsAndCategories(); } @override @@ -61,22 +67,34 @@ class _CreateInventoryScreenState super.dispose(); } - Future _loadProducts() async { + Future _loadProductsAndCategories() async { setState(() => _loadingProducts = true); try { final token = await ref.read(authStateProvider.future); final api = ref.read(apiClientProvider); - final data = await api.getJson(ProductApiPaths.mine, token: token); - final list = data is List - ? data - : (data is Map && data['items'] is List) - ? data['items'] as List + final results = await Future.wait([ + api.getJson(ProductApiPaths.mine, token: token), + api.getJson(CategoryApiPaths.tree, token: token), + ]); + final productData = results[0]; + final categoryData = results[1]; + final list = productData is List + ? productData + : (productData is Map && productData['items'] is List) + ? productData['items'] as List + : const []; + final categoryList = categoryData is List + ? categoryData + : (categoryData is Map && categoryData['items'] is List) + ? categoryData['items'] as List : const []; if (mounted) { setState(() { - _products = list - .map((e) => e as Map) + _products = list.map((e) => e as Map).toList(); + _categoryTree = categoryList + .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) .toList(); + _categoryOptions = _flattenCategoryOptions(_categoryTree); _loadingProducts = false; }); } @@ -90,6 +108,78 @@ class _CreateInventoryScreenState } } + List _flattenCategoryOptions( + List nodes, [ + List parents = const [], + ]) { + final result = []; + for (final node in nodes) { + final pathParts = [...parents, node.name]; + final path = pathParts.join(' > '); + result.add((value: node.id.toString(), label: path)); + result.addAll(_flattenCategoryOptions(node.children, pathParts)); + } + return result; + } + + Map? _selectedProduct() { + if (_selectedProductId == null) return null; + for (final product in _products) { + if ((product['id'] as num).toInt() == _selectedProductId) { + return product; + } + } + return null; + } + + List _productOptions() { + final source = _selectedCategoryId == null + ? _products + : _products + .where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId) + .toList(); + + final selected = _selectedProduct(); + final withSelected = [...source]; + if (selected != null && + !withSelected.any((p) => (p['id'] as num).toInt() == _selectedProductId)) { + withSelected.add(selected); + } + + withSelected.sort((a, b) { + final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase(); + final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase(); + return aName.compareTo(bName); + }); + + return withSelected + .map( + (p) => ( + id: (p['id'] as num).toInt(), + name: (p['canonicalName'] ?? p['name'] ?? '').toString(), + categoryId: (p['categoryId'] as num?)?.toInt(), + ), + ) + .toList(); + } + + Future _pickCategory() async { + final selected = await CategoryThenProductPicker.showCategorySheet( + context, + categoryTree: _categoryTree, + preselectedCategoryId: _selectedCategoryId, + ); + if (selected == null || !mounted) return; + setState(() { + _selectedCategoryId = selected.id; + final current = _selectedProduct(); + final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); + if (currentCategoryId != _selectedCategoryId) { + _selectedProductId = null; + } + }); + } + Future _pickDate(bool isBestBefore) async { final picked = await showDatePicker( context: context, @@ -182,21 +272,7 @@ class _CreateInventoryScreenState @override Widget build(BuildContext context) { - final sortedProducts = [..._products] - ..sort((a, b) { - final aName = (a['canonicalName'] ?? a['name'] ?? '').toString(); - final bName = (b['canonicalName'] ?? b['name'] ?? '').toString(); - return aName.toLowerCase().compareTo(bName.toLowerCase()); - }); - final productOptions = sortedProducts - .map( - (p) => ( - id: (p['id'] as num).toInt(), - name: (p['canonicalName'] ?? p['name'] ?? '').toString(), - categoryId: (p['categoryId'] as num?)?.toInt(), - ), - ) - .toList(); + final productOptions = _productOptions(); final isInventory = _destination == ImportDestination.inventory; @@ -226,6 +302,47 @@ class _CreateInventoryScreenState : (selected) => setState(() => _destination = selected.first), ), const SizedBox(height: 16), + SearchableCategoryField( + options: _categoryOptions, + value: _selectedCategoryId?.toString(), + label: 'Kategori (sökbar)', + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedCategoryId = int.tryParse(value); + final current = _selectedProduct(); + final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); + if (currentCategoryId != _selectedCategoryId) { + _selectedProductId = null; + } + }); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _loadingProducts || _saving || _categoryTree.isEmpty + ? null + : _pickCategory, + icon: const Icon(Icons.category_outlined), + label: const Text('Välj kategori'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _saving + ? null + : () { + setState(() { + _selectedCategoryId = null; + }); + }, + icon: const Icon(Icons.clear), + label: const Text('Rensa kategori'), + ), + ], + ), + const SizedBox(height: 12), ProductPickerField( products: productOptions, value: _selectedProductId, diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart index 39ba8c1a..6cae8638 100644 --- a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -3,11 +3,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_providers.dart'; import '../../../core/utils/formatters.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; +import '../../../core/ui/category_then_product_picker.dart'; +import '../../../core/ui/product_picker_field.dart'; +import '../../../core/ui/searchable_category_field.dart'; import '../../auth/data/auth_providers.dart'; +import '../../admin/domain/admin_category_node.dart'; import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; @@ -30,10 +36,16 @@ class _InventoryEditScreenState extends ConsumerState { final _commentController = TextEditingController(); bool _initialized = false; + bool _loadingProducts = false; bool _opened = false; DateTime? _purchaseDate; DateTime? _bestBeforeDate; bool _saving = false; + int? _selectedProductId; + int? _selectedCategoryId; + List> _products = []; + List _categoryTree = []; + List _categoryOptions = []; @override void dispose() { @@ -47,6 +59,7 @@ class _InventoryEditScreenState extends ConsumerState { void _initControllers(InventoryItem item) { _initialized = true; + _selectedProductId = item.productId; _quantityController.text = item.quantity.toString(); _unitController.text = item.unit; _locationController.text = item.location ?? ''; @@ -60,6 +73,132 @@ class _InventoryEditScreenState extends ConsumerState { : null; } + @override + void initState() { + super.initState(); + _loadProductsAndCategories(); + } + + Future _loadProductsAndCategories() async { + setState(() => _loadingProducts = true); + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final results = await Future.wait([ + api.getJson(ProductApiPaths.mine, token: token), + api.getJson(CategoryApiPaths.tree, token: token), + ]); + + final productData = results[0]; + final categoryData = results[1]; + final productList = productData is List + ? productData + : (productData is Map && productData['items'] is List) + ? productData['items'] as List + : const []; + final categoryList = categoryData is List + ? categoryData + : (categoryData is Map && categoryData['items'] is List) + ? categoryData['items'] as List + : const []; + + final products = productList.map((e) => e as Map).toList(); + final categoryTree = categoryList + .map((e) => AdminCategoryNode.fromJson(Map.from(e as Map))) + .toList(); + final categoryOptions = _flattenCategoryOptions(categoryTree); + + if (!mounted) return; + setState(() { + _products = products; + _categoryTree = categoryTree; + _categoryOptions = categoryOptions; + + final selected = _selectedProduct(); + if (selected != null) { + _selectedCategoryId = (selected['categoryId'] as num?)?.toInt(); + } + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } finally { + if (mounted) setState(() => _loadingProducts = false); + } + } + + List _flattenCategoryOptions( + List nodes, [ + List parents = const [], + ]) { + final result = []; + for (final node in nodes) { + final pathParts = [...parents, node.name]; + final path = pathParts.join(' > '); + result.add((value: node.id.toString(), label: path)); + result.addAll(_flattenCategoryOptions(node.children, pathParts)); + } + return result; + } + + Map? _selectedProduct() { + if (_selectedProductId == null) return null; + for (final product in _products) { + if ((product['id'] as num).toInt() == _selectedProductId) { + return product; + } + } + return null; + } + + List _productOptions() { + final source = _selectedCategoryId == null + ? _products + : _products.where((p) => (p['categoryId'] as num?)?.toInt() == _selectedCategoryId).toList(); + + final selected = _selectedProduct(); + final withSelected = [...source]; + if (selected != null && !withSelected.any((p) => (p['id'] as num).toInt() == _selectedProductId)) { + withSelected.add(selected); + } + + withSelected.sort((a, b) { + final aName = (a['canonicalName'] ?? a['name'] ?? '').toString().toLowerCase(); + final bName = (b['canonicalName'] ?? b['name'] ?? '').toString().toLowerCase(); + return aName.compareTo(bName); + }); + + return withSelected + .map( + (p) => ( + id: (p['id'] as num).toInt(), + name: (p['canonicalName'] ?? p['name'] ?? '').toString(), + categoryId: (p['categoryId'] as num?)?.toInt(), + ), + ) + .toList(); + } + + Future _pickCategory() async { + final selected = await CategoryThenProductPicker.showCategorySheet( + context, + categoryTree: _categoryTree, + preselectedCategoryId: _selectedCategoryId, + ); + if (selected == null || !mounted) return; + setState(() { + _selectedCategoryId = selected.id; + final current = _selectedProduct(); + final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); + if (currentCategoryId != _selectedCategoryId) { + _selectedProductId = null; + } + }); + } + Future _pickDate(bool isBestBefore) async { final picked = await showDatePicker( context: context, @@ -84,6 +223,7 @@ class _InventoryEditScreenState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); final body = { + 'productId': _selectedProductId, 'quantity': double.parse(_quantityController.text.trim().replaceAll(',', '.')), 'unit': _unitController.text.trim(), @@ -139,11 +279,61 @@ class _InventoryEditScreenState extends ConsumerState { child: ListView( padding: const EdgeInsets.all(16), children: [ - Text( - item.productName, - style: Theme.of(context).textTheme.titleMedium, - ), + Text(item.displayName, style: Theme.of(context).textTheme.titleMedium), + if (item.categoryPath != null && item.categoryPath!.trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text(item.categoryPath!, style: Theme.of(context).textTheme.bodySmall), + ], const SizedBox(height: 16), + SearchableCategoryField( + options: _categoryOptions, + value: _selectedCategoryId?.toString(), + label: 'Kategori (sökbar)', + onChanged: (value) { + if (value == null) return; + setState(() { + _selectedCategoryId = int.tryParse(value); + final current = _selectedProduct(); + final currentCategoryId = (current?['categoryId'] as num?)?.toInt(); + if (currentCategoryId != _selectedCategoryId) { + _selectedProductId = null; + } + }); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _loadingProducts || _saving || _categoryTree.isEmpty + ? null + : _pickCategory, + icon: const Icon(Icons.category_outlined), + label: const Text('Välj kategori'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _saving + ? null + : () { + setState(() { + _selectedCategoryId = null; + }); + }, + icon: const Icon(Icons.clear), + label: const Text('Rensa kategori'), + ), + ], + ), + const SizedBox(height: 12), + ProductPickerField( + products: _productOptions(), + value: _selectedProductId, + isLoading: _loadingProducts, + enabled: !_saving, + label: context.l10n.inventoryProductLabel, + onChanged: (value) => setState(() => _selectedProductId = value), + ), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index b8f48f08..94483714 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -154,7 +154,7 @@ class _InventoryScreenState extends ConsumerState { final units = []; final seenUnits = {}; for (final item in selectedItems) { - final unit = (item.unit as String).trim(); + final unit = item.unit.trim(); final key = unit.toLowerCase(); if (seenUnits.add(key)) { units.add(unit);