feat: implement dropdowns for unit and location selection in inventory forms; add product sorting functionality
This commit is contained in:
@@ -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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user