feat: implement dropdowns for unit and location selection in inventory forms; add product sorting functionality

This commit is contained in:
Nils-Johan Gynther
2026-04-22 10:04:57 +02:00
parent 296a89b165
commit 655adf66ae
4 changed files with 439 additions and 50 deletions
+31
View File
@@ -0,0 +1,31 @@
const inventoryLocationOptions = <String>[
'Kyl',
'Frys',
'Skafferi',
];
class UnitOption {
final String value;
final String label;
const UnitOption({required this.value, required this.label});
}
const unitOptions = <UnitOption>[
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'),
];
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_providers.dart'; import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart'; import '../data/inventory_providers.dart';
@@ -135,6 +136,13 @@ class _CreateInventoryScreenState
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar(title: const Text('Lagg till inventariepost')), appBar: AppBar(title: const Text('Lagg till inventariepost')),
body: Form( body: Form(
@@ -142,24 +150,9 @@ class _CreateInventoryScreenState
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Autocomplete<Map<String, dynamic>>( DropdownButtonFormField<int>(
optionsBuilder: (textEditingValue) { value: _selectedProductId,
if (textEditingValue.text.isEmpty) return const []; isExpanded: true,
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( decoration: InputDecoration(
labelText: 'Produkt *', labelText: 'Produkt *',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
@@ -174,9 +167,22 @@ class _CreateInventoryScreenState
) )
: null, : null,
), ),
enabled: !_saving, items: sortedProducts
); .map(
}, (product) => DropdownMenuItem<int>(
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), const SizedBox(height: 12),
Row( Row(
@@ -205,27 +211,56 @@ class _CreateInventoryScreenState
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextFormField( child: DropdownButtonFormField<String>(
controller: _unitController, value: _unitController.text.trim().isEmpty
? null
: _unitController.text.trim(),
isExpanded: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Enhet *', labelText: 'Enhet *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, items: unitOptions
validator: (v) => .map(
(v == null || v.trim().isEmpty) ? 'Ange enhet' : null, (option) => DropdownMenuItem<String>(
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), const SizedBox(height: 12),
TextFormField( DropdownButtonFormField<String>(
controller: _locationController, value: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
isExpanded: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Plats (valfri)', labelText: 'Plats (valfri)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, items: inventoryLocationOptions
.map(
(location) => DropdownMenuItem<String>(
value: location,
child: Text(location),
),
)
.toList(),
onChanged: _saving
? null
: (value) => setState(() {
_locationController.text = value ?? '';
}),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart'; import '../data/inventory_providers.dart';
@@ -173,13 +174,27 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: TextFormField( child: DropdownButtonFormField<String>(
controller: _unitController, value: _unitController.text.trim().isEmpty
? null
: _unitController.text.trim(),
isExpanded: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Enhet *', labelText: 'Enhet *',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, items: unitOptions
.map(
(option) => DropdownMenuItem<String>(
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) validator: (v) => (v == null || v.trim().isEmpty)
? 'Ange enhet' ? 'Ange enhet'
: null, : null,
@@ -188,13 +203,27 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( DropdownButtonFormField<String>(
controller: _locationController, value: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
isExpanded: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Plats', labelText: 'Plats',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
enabled: !_saving, items: inventoryLocationOptions
.map(
(location) => DropdownMenuItem<String>(
value: location,
child: Text(location),
),
)
.toList(),
onChanged: _saving
? null
: (value) =>
setState(() => _locationController.text = value ?? ''),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
@@ -4,10 +4,48 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_exception.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 '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/recipe_providers.dart'; import '../data/recipe_providers.dart';
import '../domain/recipe.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 { class RecipeEditScreen extends ConsumerStatefulWidget {
final int recipeId; final int recipeId;
@@ -26,10 +64,19 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
late TextEditingController _descCtrl; late TextEditingController _descCtrl;
late TextEditingController _servingsCtrl; late TextEditingController _servingsCtrl;
late TextEditingController _instructionsCtrl; late TextEditingController _instructionsCtrl;
final List<_EditableIngredient> _ingredients = [];
List<Map<String, dynamic>> _allProducts = [];
bool _isLoadingProducts = false;
bool _isSaving = false; bool _isSaving = false;
String? _saveError; String? _saveError;
@override
void initState() {
super.initState();
_loadProducts();
}
@override @override
void dispose() { void dispose() {
if (_initialized) { if (_initialized) {
@@ -37,6 +84,9 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
_descCtrl.dispose(); _descCtrl.dispose();
_servingsCtrl.dispose(); _servingsCtrl.dispose();
_instructionsCtrl.dispose(); _instructionsCtrl.dispose();
for (final ingredient in _ingredients) {
ingredient.dispose();
}
} }
super.dispose(); super.dispose();
} }
@@ -48,12 +98,87 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
TextEditingController(text: recipe.servings?.toString() ?? ''); TextEditingController(text: recipe.servings?.toString() ?? '');
_instructionsCtrl = _instructionsCtrl =
TextEditingController(text: recipe.instructions ?? ''); TextEditingController(text: recipe.instructions ?? '');
_ingredients
..clear()
..addAll(recipe.ingredients.map(_EditableIngredient.fromRecipe));
_initialized = true; _initialized = true;
} }
Future<void> _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<dynamic>)
.map((e) => e as Map<String, dynamic>)
.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<void> _save() async { Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return; if (!(_formKey.currentState?.validate() ?? false)) return;
final ingredientError = _validateIngredients();
if (ingredientError != null) {
setState(() => _saveError = ingredientError);
return;
}
setState(() { setState(() {
_isSaving = true; _isSaving = true;
_saveError = null; _saveError = null;
@@ -62,6 +187,19 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final servings = int.tryParse(_servingsCtrl.text.trim()); 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( await ref.read(recipeRepositoryProvider).updateRecipe(
widget.recipeId, widget.recipeId,
{ {
@@ -73,6 +211,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
'instructions': _instructionsCtrl.text.trim().isEmpty 'instructions': _instructionsCtrl.text.trim().isEmpty
? null ? null
: _instructionsCtrl.text.trim(), : _instructionsCtrl.text.trim(),
'ingredients': ingredients,
}, },
token: token, token: token,
); );
@@ -169,12 +308,45 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
maxLines: 10, maxLines: 10,
textAlignVertical: TextAlignVertical.top, 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), const SizedBox(height: 8),
Text( Text(
'Ingredienser redigeras via Skapa recept-flödet.', 'Valj produkt, mangd och enhet for varje ingrediens.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant), 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) ...[ if (_saveError != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -201,4 +373,126 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
), ),
); );
} }
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<int>(
value: ingredient.productId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Produkt *',
border: OutlineInputBorder(),
),
items: _allProducts
.map(
(product) => DropdownMenuItem<int>(
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<String>(
value: ingredient.unit.trim().isEmpty ? null : ingredient.unit,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Enhet *',
border: OutlineInputBorder(),
),
items: unitOptions
.map(
(option) => DropdownMenuItem<String>(
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(),
),
),
],
),
),
);
}
} }