From 4919384039e884eb9582991039c9667f8ebd586e Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 24 Apr 2026 23:07:07 +0200 Subject: [PATCH] feat: improve dialog layout and error handling in pantry screen Co-authored-by: Copilot --- .../pantry/presentation/pantry_screen.dart | 361 +++++++++++++++--- 1 file changed, 306 insertions(+), 55 deletions(-) diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 0fcc277a..c74a5671 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -5,9 +5,10 @@ import 'package:logging/logging.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.dart'; +import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/product_picker_field.dart'; import '../../auth/data/auth_providers.dart'; -import '../../../core/ui/async_state_views.dart'; +import '../../inventory/data/inventory_providers.dart'; import '../data/pantry_providers.dart'; import '../domain/pantry_item.dart'; import '../domain/pantry_product.dart'; @@ -37,43 +38,135 @@ class _PantryScreenState extends ConsumerState { String? selectedLocation; String? formError; - final payload = await showDialog>(context: context, builder: (ctx) { - return StatefulBuilder(builder: (ctx, setDialogState) { - return AlertDialog( - title: Text('Lägg "${item.displayName}" i inventarie'), - content: SizedBox(width: 380, child: Column(mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: quantityController, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(labelText: 'Mängd', border: OutlineInputBorder())), - const SizedBox(height: 12), - DropdownButtonFormField(initialValue: selectedUnit, isExpanded: true, decoration: const InputDecoration(labelText: 'Enhet', border: OutlineInputBorder()), items: unitOptions.map((option) => DropdownMenuItem(value: option.value, child: Text(option.label))).toList(), onChanged: (value) { if (value == null) return; setDialogState(() => selectedUnit = value); }), - const SizedBox(height: 12), - DropdownButtonFormField(initialValue: selectedLocation, isExpanded: true, decoration: const InputDecoration(labelText: 'Plats (valfri)', border: OutlineInputBorder()), items: [const DropdownMenuItem(value: null, child: Text('Ingen plats vald')), ...inventoryLocationOptions.map((location) => DropdownMenuItem(value: location, child: Text(location))),], onChanged: (value) { setDialogState(() => selectedLocation = value); }), - if (formError != null) ...[const SizedBox(height: 8), Text(formError!, style: TextStyle(color: Theme.of(context).colorScheme.error))], - ])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Avbryt')), - FilledButton(onPressed: () { - final quantity = double.tryParse(quantityController.text.trim().replaceAll(',', '.')); - if (quantity == null || quantity <= 0) { setDialogState(() { formError = 'Ange en giltig mängd över 0.'; }); return; } - Navigator.pop(ctx, {'quantity': quantity, 'unit': selectedUnit, 'location': selectedLocation}); - }, child: const Text('Lägg till')), - ], + final payload = await showDialog>( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: Text('Lägg "${item.displayName}" i inventarie'), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: quantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Mängd', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedUnit, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Enhet', + border: OutlineInputBorder(), + ), + items: unitOptions + .map((option) => DropdownMenuItem( + value: option.value, + child: Text(option.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + setDialogState(() => selectedUnit = value); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedLocation, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Plats (valfri)', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Ingen plats vald'), + ), + ...inventoryLocationOptions.map( + (location) => DropdownMenuItem( + value: location, + child: Text(location), + ), + ), + ], + onChanged: (value) { + setDialogState(() => selectedLocation = value); + }, + ), + if (formError != null) ...[ + const SizedBox(height: 8), + Text( + formError!, + style: TextStyle(color: Theme.of(ctx).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () { + final quantity = double.tryParse( + quantityController.text.trim().replaceAll(',', '.'), + ); + if (quantity == null || quantity <= 0) { + setDialogState(() { + formError = 'Ange en giltig mängd över 0.'; + }); + return; + } + Navigator.pop(ctx, { + 'quantity': quantity, + 'unit': selectedUnit, + 'location': selectedLocation, + }); + }, + child: const Text('Lägg till'), + ), + ], + ); + }, ); - }); - }); + }, + ); quantityController.dispose(); if (payload == null) return; try { final token = await ref.read(authStateProvider.future); - await ref.read(inventoryRepositoryProvider).createInventoryItem({'productId': item.productId, 'quantity': payload['quantity'] as double, 'unit': payload['unit'] as String, if (payload['location'] != null) 'location': payload['location'] as String,}, token: token); + await ref.read(inventoryRepositoryProvider).createInventoryItem( + { + 'productId': item.productId, + 'quantity': payload['quantity'] as double, + 'unit': payload['unit'] as String, + if (payload['location'] != null) 'location': payload['location'] as String, + }, + token: token, + ); ref.invalidate(inventoryProvider); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${item.displayName} tillagd i inventarie.'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${item.displayName} tillagd i inventarie.')), + ); } catch (error) { _logger.severe('Failed to add item to inventory: $error'); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(error, context)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error, context))), + ); } } @@ -90,14 +183,32 @@ class _PantryScreenState extends ConsumerState { } catch (error) { _logger.severe('Failed to add pantry item: $error'); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(error, context)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error, context))), + ); } finally { if (mounted) setState(() => _isSubmitting = false); } } Future _removeItem(PantryItem item) async { - final confirmed = await showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text('Ta bort från baslager?'), content: Text('Vill du ta bort "${item.displayName}"?'), actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Avbryt')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Ta bort')),],)); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ta bort från baslager?'), + content: Text('Vill du ta bort "${item.displayName}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Ta bort'), + ), + ], + ), + ); if (confirmed != true) return; @@ -108,14 +219,20 @@ class _PantryScreenState extends ConsumerState { } catch (error) { _logger.severe('Failed to remove pantry item: $error'); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(error, context)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error, context))), + ); } } String _resolveCategory(PantryItem item, Map productById) { final fromTree = productById[item.productId]?.categoryPath; - if (fromTree != null && fromTree.trim().isNotEmpty) { return fromTree; } - if (item.category != null && item.category!.trim().isNotEmpty) { return item.category!; } + if (fromTree != null && fromTree.trim().isNotEmpty) { + return fromTree; + } + if (item.category != null && item.category!.trim().isNotEmpty) { + return item.category!; + } return 'Övrigt'; } @@ -131,39 +248,173 @@ class _PantryScreenState extends ConsumerState { if (pantryAsync.hasError || productsAsync.hasError) { final error = pantryAsync.error ?? productsAsync.error; _logger.severe('Error loading pantry or products: $error'); - return ErrorStateView(message: mapErrorToUserMessage(error ?? 'Okänt fel', context), onRetry: () { ref.invalidate(pantryProvider); ref.invalidate(pantryProductsProvider); },); + return ErrorStateView( + message: mapErrorToUserMessage(error ?? 'Okänt fel', context), + onRetry: () { + ref.invalidate(pantryProvider); + ref.invalidate(pantryProductsProvider); + }, + ); } - final pantryItems = pantryAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; - final products = productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; + final pantryItems = + pantryAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; + final products = + productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; final productById = {for (final product in products) product.id: product}; final pantryProductIds = pantryItems.map((e) => e.productId).toSet(); - final availableProducts = products.where((product) => !pantryProductIds.contains(product.id)).toList()..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); - final availableOptions = availableProducts.map((p) => (id: p.id, name: p.displayName)).toList(); + final availableProducts = products + .where((product) => !pantryProductIds.contains(product.id)) + .toList() + ..sort( + (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + final availableOptions = availableProducts + .map((p) => (id: p.id, name: p.displayName)) + .toList(); final grouped = >{}; for (final item in pantryItems) { final category = _resolveCategory(item, productById); grouped.putIfAbsent(category, () => []).add(item); } - final categories = grouped.keys.toList()..sort((a, b) { - if (a == 'Övrigt') return 1; - if (b == 'Övrigt') return -1; - return a.toLowerCase().compareTo(b.toLowerCase()); - }); + final categories = grouped.keys.toList() + ..sort((a, b) { + if (a == 'Övrigt') return 1; + if (b == 'Övrigt') return -1; + return a.toLowerCase().compareTo(b.toLowerCase()); + }); - return ListView(padding: const EdgeInsets.all(16), children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('Produkter du alltid räknar med att ha hemma.', style: Theme.of(context).textTheme.bodyMedium), IconButton(tooltip: 'Gå till recept', icon: const Icon(Icons.restaurant_menu), onPressed: () => context.go('/recipes')),],), - const SizedBox(height: 12), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Expanded(child: ProductPickerField(products: availableOptions, value: _selectedProductId, enabled: !_isSubmitting && availableProducts.isNotEmpty, label: 'Produkt', onChanged: (value) => setState(() => _selectedProductId = value),)), const SizedBox(width: 8), FilledButton(onPressed: (_selectedProductId == null || _isSubmitting || availableProducts.isEmpty) ? null : _addItem, child: _isSubmitting ? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Lägg till')),],), - if (availableProducts.isEmpty) ...[const SizedBox(height: 12), Text('Inga produkter tillgängliga att lägga till.', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey))], - const SizedBox(height: 20), - Text('${pantryItems.length} ${pantryItems.length == 1 ? 'produkt' : 'produkter'} i baslagret', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - if (pantryItems.isEmpty) const EmptyStateView(title: 'Baslagret är tomt', description: 'Lägg till produkter ovan.') else ...categories.map((category) { - final items = grouped[category]!..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); - return Padding(padding: const EdgeInsets.only(bottom: 16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(category, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 8), ...items.map((item) => Card(margin: const EdgeInsets.only(bottom: 8), child: ListTile(title: Text(item.displayName), trailing: Row(mainAxisSize: MainAxisSize.min, children: [const Tooltip(message: 'Konsumera (inte tillgängligt i baslager)', child: IconButton(onPressed: null, icon: Icon(Icons.remove_circle_outline))), const Tooltip(message: 'Redigera (inte tillgängligt i baslager)', child: IconButton(onPressed: null, icon: Icon(Icons.edit_outlined))), IconButton(tooltip: 'Lägg i inventarie', icon: const Icon(Icons.inventory_2_outlined), onPressed: () => _addToInventory(item)), IconButton(tooltip: 'Ta bort', icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => _removeItem(item)),],),),),],),),); - }), - ]); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Produkter du alltid räknar med att ha hemma.', + style: Theme.of(context).textTheme.bodyMedium, + ), + IconButton( + tooltip: 'Gå till recept', + icon: const Icon(Icons.restaurant_menu), + onPressed: () => context.go('/recipes'), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ProductPickerField( + products: availableOptions, + value: _selectedProductId, + enabled: !_isSubmitting && availableProducts.isNotEmpty, + label: 'Produkt', + onChanged: (value) => setState(() => _selectedProductId = value), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: + (_selectedProductId == null || _isSubmitting || availableProducts.isEmpty) + ? null + : _addItem, + child: _isSubmitting + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Lägg till'), + ), + ], + ), + if (availableProducts.isEmpty) ...[ + const SizedBox(height: 12), + Text( + 'Inga produkter tillgängliga att lägga till.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + ], + const SizedBox(height: 20), + Text( + '${pantryItems.length} ${pantryItems.length == 1 ? 'produkt' : 'produkter'} i baslagret', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (pantryItems.isEmpty) + const EmptyStateView( + title: 'Baslagret är tomt', + description: 'Lägg till produkter ovan.', + ) + else + ...categories.map((category) { + final items = grouped[category]! + ..sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Column( + children: items + .map( + (item) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(item.displayName), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Tooltip( + message: 'Konsumera (inte tillgängligt i baslager)', + child: IconButton( + onPressed: null, + icon: Icon(Icons.remove_circle_outline), + ), + ), + const Tooltip( + message: 'Redigera (inte tillgängligt i baslager)', + child: IconButton( + onPressed: null, + icon: Icon(Icons.edit_outlined), + ), + ), + IconButton( + tooltip: 'Lägg i inventarie', + icon: const Icon(Icons.inventory_2_outlined), + onPressed: () => _addToInventory(item), + ), + IconButton( + tooltip: 'Ta bort', + icon: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + onPressed: () => _removeItem(item), + ), + ], + ), + ), + ), + ) + .toList(), + ), + ], + ), + ); + }).toList(), + ], + ); } -} \ No newline at end of file +}