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
@@ -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<RecipeEditScreen> {
late TextEditingController _descCtrl;
late TextEditingController _servingsCtrl;
late TextEditingController _instructionsCtrl;
final List<_EditableIngredient> _ingredients = [];
List<Map<String, dynamic>> _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<RecipeEditScreen> {
_descCtrl.dispose();
_servingsCtrl.dispose();
_instructionsCtrl.dispose();
for (final ingredient in _ingredients) {
ingredient.dispose();
}
}
super.dispose();
}
@@ -48,12 +98,87 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
TextEditingController(text: recipe.servings?.toString() ?? '');
_instructionsCtrl =
TextEditingController(text: recipe.instructions ?? '');
_ingredients
..clear()
..addAll(recipe.ingredients.map(_EditableIngredient.fromRecipe));
_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 {
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<RecipeEditScreen> {
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<RecipeEditScreen> {
'instructions': _instructionsCtrl.text.trim().isEmpty
? null
: _instructionsCtrl.text.trim(),
'ingredients': ingredients,
},
token: token,
);
@@ -169,12 +308,45 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
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<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(),
),
),
],
),
),
);
}
}