diff --git a/flutter/lib/features/pantry/data/pantry_repository.dart b/flutter/lib/features/pantry/data/pantry_repository.dart index 6878fc9c..8e939530 100644 --- a/flutter/lib/features/pantry/data/pantry_repository.dart +++ b/flutter/lib/features/pantry/data/pantry_repository.dart @@ -1,39 +1,66 @@ +import 'package:logging/logging.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; import '../domain/pantry_item.dart'; import '../domain/pantry_product.dart'; +final _logger = Logger('PantryRepository'); + class PantryRepository { final ApiClient _api; const PantryRepository(this._api); Future> fetchPantry({String? token}) async { - final data = await _api.getJson(PantryApiPaths.list, token: token); - final list = data as List; - return list - .map((e) => PantryItem.fromJson(e as Map)) - .toList(); + try { + final data = await _api.getJson(PantryApiPaths.list, token: token); + final list = data as List; + _logger.info('Fetched ${list.length} pantry items'); + return list + .map((e) => PantryItem.fromJson(e as Map)) + .toList(); + } catch (error) { + _logger.severe('Failed to fetch pantry items: $error'); + rethrow; + } } Future> fetchProducts({String? token}) async { - final data = await _api.getJson(ProductApiPaths.list, token: token); - final list = data as List; - return list - .map((e) => PantryProduct.fromJson(e as Map)) - .toList(); + try { + final data = await _api.getJson(ProductApiPaths.list, token: token); + final list = data as List; + _logger.info('Fetched ${list.length} products'); + return list + .map((e) => PantryProduct.fromJson(e as Map)) + .toList(); + } catch (error) { + _logger.severe('Failed to fetch products: $error'); + rethrow; + } } Future createPantryItem(int productId, {String? token}) async { - final data = await _api.postJson( - PantryApiPaths.list, - body: {'productId': productId}, - token: token, - ); - return PantryItem.fromJson(data as Map); + try { + final data = await _api.postJson( + PantryApiPaths.list, + body: {'productId': productId}, + token: token, + ); + _logger.info('Created pantry item for product ID: $productId'); + return PantryItem.fromJson(data as Map); + } catch (error) { + _logger.severe('Failed to create pantry item: $error'); + rethrow; + } } Future deletePantryItem(int id, {String? token}) async { - await _api.deleteJson(PantryApiPaths.remove(id), token: token); + try { + await _api.deleteJson(PantryApiPaths.remove(id), token: token); + _logger.info('Deleted pantry item with ID: $id'); + } catch (error) { + _logger.severe('Failed to delete pantry item: $error'); + rethrow; + } } } \ No newline at end of file diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index d0c06c0e..0fcc277a 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -1,17 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/ui/product_picker_field.dart'; -import '../../../features/inventory/data/inventory_providers.dart'; import '../../auth/data/auth_providers.dart'; import '../../../core/ui/async_state_views.dart'; import '../data/pantry_providers.dart'; import '../domain/pantry_item.dart'; import '../domain/pantry_product.dart'; +final _logger = Logger('PantryScreen'); + class PantryScreen extends ConsumerStatefulWidget { const PantryScreen({super.key}); @@ -23,145 +25,55 @@ class _PantryScreenState extends ConsumerState { int? _selectedProductId; bool _isSubmitting = false; + @override + void initState() { + super.initState(); + _logger.info('Initializing PantryScreen'); + } + Future _addToInventory(PantryItem item) async { final quantityController = TextEditingController(text: '1'); String selectedUnit = 'st'; 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(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')), + ], ); - }, - ); + }); + }); 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)))); } } @@ -172,66 +84,38 @@ class _PantryScreenState extends ConsumerState { setState(() => _isSubmitting = true); try { final token = await ref.read(authStateProvider.future); - await ref - .read(pantryRepositoryProvider) - .createPantryItem(selectedId, token: token); + await ref.read(pantryRepositoryProvider).createPantryItem(selectedId, token: token); ref.invalidate(pantryProvider); if (mounted) setState(() => _selectedProductId = null); } 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; try { final token = await ref.read(authStateProvider.future); - await ref - .read(pantryRepositoryProvider) - .deletePantryItem(item.id, token: token); + await ref.read(pantryRepositoryProvider).deletePantryItem(item.id, token: token); ref.invalidate(pantryProvider); } 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'; } @@ -246,157 +130,40 @@ class _PantryScreenState extends ConsumerState { if (pantryAsync.hasError || productsAsync.hasError) { final error = pantryAsync.error ?? productsAsync.error; - return ErrorStateView( - message: mapErrorToUserMessage(error ?? 'Okänt fel', context), - onRetry: () { - ref.invalidate(pantryProvider); - ref.invalidate(pantryProductsProvider); - }, - ); + _logger.severe('Error loading pantry or products: $error'); + 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 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'), - ), - ], - ), - 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), ...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)),],),),),],),),); + }), + ]); } } \ No newline at end of file diff --git a/flutter/lib/features/recipes/presentation/recipes_screen.dart b/flutter/lib/features/recipes/presentation/recipes_screen.dart index bd33f543..0ed1cf63 100644 --- a/flutter/lib/features/recipes/presentation/recipes_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipes_screen.dart @@ -45,6 +45,7 @@ class _RecipesScreenState extends ConsumerState { actions: [ PopupMenuButton( icon: const Icon(Icons.grid_view), + tooltip: 'Välj antal kolumner', onSelected: (int columns) { setState(() { _selectedColumns = columns;