From 655adf66ae7273d78e9d78573d9eb2ac596de42c Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 10:04:57 +0200 Subject: [PATCH] feat: implement dropdowns for unit and location selection in inventory forms; add product sorting functionality --- flutter/lib/core/forms/form_options.dart | 31 ++ .../presentation/create_inventory_screen.dart | 121 ++++--- .../presentation/inventory_edit_screen.dart | 41 ++- .../presentation/recipe_edit_screen.dart | 296 +++++++++++++++++- 4 files changed, 439 insertions(+), 50 deletions(-) create mode 100644 flutter/lib/core/forms/form_options.dart diff --git a/flutter/lib/core/forms/form_options.dart b/flutter/lib/core/forms/form_options.dart new file mode 100644 index 00000000..0d9e3e8d --- /dev/null +++ b/flutter/lib/core/forms/form_options.dart @@ -0,0 +1,31 @@ +const inventoryLocationOptions = [ + 'Kyl', + 'Frys', + 'Skafferi', +]; + +class UnitOption { + final String value; + final String label; + + const UnitOption({required this.value, required this.label}); +} + +const unitOptions = [ + UnitOption(value: 'g', label: 'g (gram)'), + UnitOption(value: 'hg', label: 'hg (hektogram)'), + UnitOption(value: 'kg', label: 'kg (kilogram)'), + UnitOption(value: 'mg', label: 'mg (milligram)'), + UnitOption(value: 'ml', label: 'ml (milliliter)'), + UnitOption(value: 'cl', label: 'cl (centiliter)'), + UnitOption(value: 'dl', label: 'dl (deciliter)'), + UnitOption(value: 'l', label: 'l (liter)'), + UnitOption(value: 'krm', label: 'krm (kryddmatt)'), + UnitOption(value: 'tsk', label: 'tsk (tesked)'), + UnitOption(value: 'msk', label: 'msk (matsked)'), + UnitOption(value: 'st', label: 'st (styck)'), + UnitOption(value: 'port', label: 'port (portioner)'), + UnitOption(value: 'forp', label: 'forp (forpackning)'), + UnitOption(value: 'klyfta', label: 'klyfta'), + UnitOption(value: 'efter smak', label: 'efter smak'), +]; \ No newline at end of file diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 35ddbc0e..6856efdc 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_providers.dart'; +import '../../../core/forms/form_options.dart'; import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; @@ -135,6 +136,13 @@ 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()); + }); + return Scaffold( appBar: AppBar(title: const Text('Lagg till inventariepost')), body: Form( @@ -142,41 +150,39 @@ class _CreateInventoryScreenState child: ListView( padding: const EdgeInsets.all(16), children: [ - Autocomplete>( - optionsBuilder: (textEditingValue) { - if (textEditingValue.text.isEmpty) return const []; - final q = textEditingValue.text.toLowerCase(); - return _products - .where((p) => - (p['name'] as String).toLowerCase().contains(q)) - .take(10); - }, - displayStringForOption: (option) => option['name'] as String, - onSelected: (option) { - setState(() => _selectedProductId = option['id'] as int); - }, - fieldViewBuilder: - (context, controller, focusNode, onFieldSubmitted) { - return TextFormField( - controller: controller, - focusNode: focusNode, - decoration: InputDecoration( - labelText: 'Produkt *', - border: const OutlineInputBorder(), - suffixIcon: _loadingProducts - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - ), - enabled: !_saving, - ); - }, + DropdownButtonFormField( + value: _selectedProductId, + isExpanded: true, + decoration: InputDecoration( + labelText: 'Produkt *', + border: const OutlineInputBorder(), + suffixIcon: _loadingProducts + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + ), + items: sortedProducts + .map( + (product) => DropdownMenuItem( + value: product['id'] as int, + child: Text( + ((product['canonicalName'] ?? product['name']) as Object) + .toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (_loadingProducts || _saving) + ? null + : (value) => setState(() => _selectedProductId = value), + validator: (value) => value == null ? 'Valj produkt' : null, ), const SizedBox(height: 12), Row( @@ -205,27 +211,56 @@ class _CreateInventoryScreenState ), const SizedBox(width: 8), Expanded( - child: TextFormField( - controller: _unitController, + child: DropdownButtonFormField( + value: _unitController.text.trim().isEmpty + ? null + : _unitController.text.trim(), + isExpanded: true, decoration: const InputDecoration( labelText: 'Enhet *', border: OutlineInputBorder(), ), - enabled: !_saving, - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Ange enhet' : null, + items: unitOptions + .map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: _saving + ? null + : (value) => + setState(() => _unitController.text = value ?? ''), + validator: (value) => + (value == null || value.trim().isEmpty) ? 'Ange enhet' : null, ), ), ], ), const SizedBox(height: 12), - TextFormField( - controller: _locationController, + DropdownButtonFormField( + value: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + isExpanded: true, decoration: const InputDecoration( labelText: 'Plats (valfri)', border: OutlineInputBorder(), ), - enabled: !_saving, + items: inventoryLocationOptions + .map( + (location) => DropdownMenuItem( + value: location, + child: Text(location), + ), + ) + .toList(), + onChanged: _saving + ? null + : (value) => setState(() { + _locationController.text = value ?? ''; + }), ), const SizedBox(height: 12), TextFormField( diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart index 1722d326..5189747c 100644 --- a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; +import '../../../core/forms/form_options.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; @@ -173,13 +174,27 @@ class _InventoryEditScreenState extends ConsumerState { ), const SizedBox(width: 8), Expanded( - child: TextFormField( - controller: _unitController, + child: DropdownButtonFormField( + value: _unitController.text.trim().isEmpty + ? null + : _unitController.text.trim(), + isExpanded: true, decoration: const InputDecoration( labelText: 'Enhet *', border: OutlineInputBorder(), ), - enabled: !_saving, + items: unitOptions + .map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: _saving + ? null + : (value) => + setState(() => _unitController.text = value ?? ''), validator: (v) => (v == null || v.trim().isEmpty) ? 'Ange enhet' : null, @@ -188,13 +203,27 @@ class _InventoryEditScreenState extends ConsumerState { ], ), const SizedBox(height: 12), - TextFormField( - controller: _locationController, + DropdownButtonFormField( + value: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + isExpanded: true, decoration: const InputDecoration( labelText: 'Plats', border: OutlineInputBorder(), ), - enabled: !_saving, + items: inventoryLocationOptions + .map( + (location) => DropdownMenuItem( + value: location, + child: Text(location), + ), + ) + .toList(), + onChanged: _saving + ? null + : (value) => + setState(() => _locationController.text = value ?? ''), ), const SizedBox(height: 12), TextFormField( diff --git a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart index 1ed0c7ba..1424e8ae 100644 --- a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart @@ -4,10 +4,48 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_providers.dart'; +import '../../../core/forms/form_options.dart'; import '../../../core/ui/async_state_views.dart'; import '../../auth/data/auth_providers.dart'; import '../data/recipe_providers.dart'; import '../domain/recipe.dart'; +import '../domain/recipe_ingredient.dart'; + +class _EditableIngredient { + int? productId; + String productName; + final TextEditingController quantityCtrl; + String unit; + final TextEditingController noteCtrl; + + _EditableIngredient({ + required this.productId, + required this.productName, + required String quantity, + required this.unit, + String note = '', + }) : quantityCtrl = TextEditingController(text: quantity), + noteCtrl = TextEditingController(text: note); + + factory _EditableIngredient.fromRecipe(RecipeIngredient ingredient) { + final quantity = ingredient.quantity == ingredient.quantity.truncateToDouble() + ? ingredient.quantity.toInt().toString() + : ingredient.quantity.toString(); + return _EditableIngredient( + productId: ingredient.productId, + productName: ingredient.productName, + quantity: quantity, + unit: ingredient.unit, + note: ingredient.note ?? '', + ); + } + + void dispose() { + quantityCtrl.dispose(); + noteCtrl.dispose(); + } +} class RecipeEditScreen extends ConsumerStatefulWidget { final int recipeId; @@ -26,10 +64,19 @@ class _RecipeEditScreenState extends ConsumerState { late TextEditingController _descCtrl; late TextEditingController _servingsCtrl; late TextEditingController _instructionsCtrl; + final List<_EditableIngredient> _ingredients = []; + List> _allProducts = []; + bool _isLoadingProducts = false; bool _isSaving = false; String? _saveError; + @override + void initState() { + super.initState(); + _loadProducts(); + } + @override void dispose() { if (_initialized) { @@ -37,6 +84,9 @@ class _RecipeEditScreenState extends ConsumerState { _descCtrl.dispose(); _servingsCtrl.dispose(); _instructionsCtrl.dispose(); + for (final ingredient in _ingredients) { + ingredient.dispose(); + } } super.dispose(); } @@ -48,12 +98,87 @@ class _RecipeEditScreenState extends ConsumerState { TextEditingController(text: recipe.servings?.toString() ?? ''); _instructionsCtrl = TextEditingController(text: recipe.instructions ?? ''); + _ingredients + ..clear() + ..addAll(recipe.ingredients.map(_EditableIngredient.fromRecipe)); _initialized = true; } + Future _loadProducts() async { + setState(() => _isLoadingProducts = true); + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final data = await api.getJson('/products', token: token); + if (!mounted) return; + final products = (data as List) + .map((e) => e as Map) + .toList() + ..sort((a, b) { + final aName = (a['canonicalName'] ?? a['name'] ?? '').toString(); + final bName = (b['canonicalName'] ?? b['name'] ?? '').toString(); + return aName.toLowerCase().compareTo(bName.toLowerCase()); + }); + setState(() { + _allProducts = products; + _isLoadingProducts = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _isLoadingProducts = false); + } + } + + void _addIngredient() { + setState(() { + _ingredients.add( + _EditableIngredient( + productId: null, + productName: '', + quantity: '', + unit: 'st', + ), + ); + }); + } + + void _removeIngredient(int index) { + setState(() { + final ingredient = _ingredients.removeAt(index); + ingredient.dispose(); + }); + } + + String? _validateIngredients() { + if (_ingredients.isEmpty) { + return 'Minst en ingrediens kravs.'; + } + for (final ingredient in _ingredients) { + if (ingredient.productId == null) { + return 'Valj produkt for alla ingredienser.'; + } + final quantity = double.tryParse( + ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), + ); + if (quantity == null || quantity < 0) { + return 'Ange giltig mangd for alla ingredienser.'; + } + if (ingredient.unit.trim().isEmpty) { + return 'Valj enhet for alla ingredienser.'; + } + } + return null; + } + Future _save() async { if (!(_formKey.currentState?.validate() ?? false)) return; + final ingredientError = _validateIngredients(); + if (ingredientError != null) { + setState(() => _saveError = ingredientError); + return; + } + setState(() { _isSaving = true; _saveError = null; @@ -62,6 +187,19 @@ class _RecipeEditScreenState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); final servings = int.tryParse(_servingsCtrl.text.trim()); + final ingredients = _ingredients + .map( + (ingredient) => { + 'productId': ingredient.productId, + 'quantity': double.parse( + ingredient.quantityCtrl.text.trim().replaceAll(',', '.'), + ), + 'unit': ingredient.unit, + if (ingredient.noteCtrl.text.trim().isNotEmpty) + 'note': ingredient.noteCtrl.text.trim(), + }, + ) + .toList(); await ref.read(recipeRepositoryProvider).updateRecipe( widget.recipeId, { @@ -73,6 +211,7 @@ class _RecipeEditScreenState extends ConsumerState { 'instructions': _instructionsCtrl.text.trim().isEmpty ? null : _instructionsCtrl.text.trim(), + 'ingredients': ingredients, }, token: token, ); @@ -169,12 +308,45 @@ class _RecipeEditScreenState extends ConsumerState { maxLines: 10, textAlignVertical: TextAlignVertical.top, ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Text( + 'Ingredienser', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + OutlinedButton.icon( + onPressed: _isSaving ? null : _addIngredient, + icon: const Icon(Icons.add), + label: const Text('Lagg till'), + ), + ], + ), const SizedBox(height: 8), Text( - 'Ingredienser redigeras via Skapa recept-flödet.', + 'Valj produkt, mangd och enhet for varje ingrediens.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant), ), + const SizedBox(height: 8), + if (_isLoadingProducts) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + if (_ingredients.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Inga ingredienser tillagda an.'), + ), + ), + ...List.generate( + _ingredients.length, + (index) => _buildIngredientCard(context, index), + ), if (_saveError != null) ...[ const SizedBox(height: 16), Text( @@ -201,4 +373,126 @@ class _RecipeEditScreenState extends ConsumerState { ), ); } + + Widget _buildIngredientCard(BuildContext context, int index) { + final ingredient = _ingredients[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Ingrediens ${index + 1}', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + onPressed: _isSaving ? null : () => _removeIngredient(index), + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort ingrediens', + ), + ], + ), + DropdownButtonFormField( + value: ingredient.productId, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Produkt *', + border: OutlineInputBorder(), + ), + items: _allProducts + .map( + (product) => DropdownMenuItem( + value: product['id'] as int, + child: Text( + ((product['canonicalName'] ?? product['name']) as Object) + .toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (_isSaving || _isLoadingProducts) + ? null + : (value) { + if (value == null) return; + final product = _allProducts.firstWhere( + (item) => item['id'] == value, + orElse: () => {'name': ''}, + ); + setState(() { + ingredient.productId = value; + ingredient.productName = + ((product['canonicalName'] ?? product['name']) as Object) + .toString(); + }); + }, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: ingredient.quantityCtrl, + decoration: const InputDecoration( + labelText: 'Mangd *', + border: OutlineInputBorder(), + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Ange mangd'; + } + if (double.tryParse(value.trim().replaceAll(',', '.')) == + null) { + return 'Ogiltigt tal'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: ingredient.unit.trim().isEmpty ? null : ingredient.unit, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Enhet *', + border: OutlineInputBorder(), + ), + items: unitOptions + .map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label, overflow: TextOverflow.ellipsis), + ), + ) + .toList(), + onChanged: _isSaving + ? null + : (value) => + setState(() => ingredient.unit = value ?? ''), + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: ingredient.noteCtrl, + decoration: const InputDecoration( + labelText: 'Notering (valfritt)', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } }