From dd05fed2793aa3142f5df5d68186dc3c98e19dcd Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 10:45:37 +0200 Subject: [PATCH] feat: add pantry management features including repository, providers, and UI integration --- flutter/lib/core/api/api_paths.dart | 5 + flutter/lib/core/router/app_router.dart | 5 + flutter/lib/core/ui/app_shell.dart | 6 + .../presentation/inventory_screen.dart | 88 +++- .../pantry/data/pantry_providers.dart | 28 ++ .../pantry/data/pantry_repository.dart | 39 ++ .../features/pantry/domain/pantry_item.dart | 33 ++ .../pantry/domain/pantry_product.dart | 29 ++ .../pantry/presentation/pantry_screen.dart | 389 ++++++++++++++++++ 9 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 flutter/lib/features/pantry/data/pantry_providers.dart create mode 100644 flutter/lib/features/pantry/data/pantry_repository.dart create mode 100644 flutter/lib/features/pantry/domain/pantry_item.dart create mode 100644 flutter/lib/features/pantry/domain/pantry_product.dart create mode 100644 flutter/lib/features/pantry/presentation/pantry_screen.dart diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 18e07ab5..433c9f33 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -20,4 +20,9 @@ class InventoryApiPaths { static String remove(int id) => '/inventory/$id'; static String consume(int id) => '/inventory/$id/consume'; static String consumptionHistory(int id) => '/inventory/$id/consumption-history'; +} + +class PantryApiPaths { + static const list = '/pantry'; + static String remove(int id) => '/pantry/$id'; } \ No newline at end of file diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 7f9f36d3..755d5556 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ import '../../features/inventory/presentation/create_inventory_screen.dart'; import '../../features/inventory/presentation/inventory_edit_screen.dart'; import '../../features/inventory/presentation/consume_inventory_screen.dart'; import '../../features/inventory/presentation/consumption_history_screen.dart'; +import '../../features/pantry/presentation/pantry_screen.dart'; final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -158,6 +159,10 @@ final appRouterProvider = Provider((ref) { path: '/inventory', builder: (context, state) => const InventoryScreen(), ), + GoRoute( + path: '/baslager', + builder: (context, state) => const PantryScreen(), + ), GoRoute( path: '/profile', builder: (context, state) => const ProfileScreen(), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 81d1015d..c002a926 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -27,6 +27,12 @@ class AppShell extends ConsumerWidget { icon: Icons.inventory_2_outlined, label: 'Inventarie', ), + _AppDestination( + path: '/baslager', + title: 'Baslager', + icon: Icons.storefront_outlined, + label: 'Baslager', + ), _AppDestination( path: '/profile', title: 'Profil', diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index 6b3bb0ba..f35ffe8f 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/api/api_error_mapper.dart'; import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; import '../data/inventory_providers.dart'; import '../domain/inventory_item.dart'; @@ -79,13 +81,35 @@ class _InventoryTile extends StatelessWidget { return ListTile( title: Text(item.productName), subtitle: Text(subtitle), - trailing: item.opened - ? const Chip( - label: Text('Öppnad'), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ) - : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.opened) + const Padding( + padding: EdgeInsets.only(right: 4), + child: Chip( + label: Text('Oppnad'), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + Tooltip( + message: 'Konsumera', + child: IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: () => context.push('/inventory/${item.id}/consume'), + ), + ), + Tooltip( + message: 'Redigera', + child: IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => context.push('/inventory/${item.id}/edit'), + ), + ), + _DeleteInventoryButton(item: item), + ], + ), onTap: () => context.push('/inventory/${item.id}'), ); } @@ -99,3 +123,53 @@ class _InventoryTile extends StatelessWidget { } } } + +class _DeleteInventoryButton extends ConsumerWidget { + final InventoryItem item; + + const _DeleteInventoryButton({required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Tooltip( + message: 'Ta bort', + child: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ta bort inventariepost?'), + content: Text('Vill du ta bort "${item.productName}"?'), + 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(inventoryRepositoryProvider) + .deleteInventoryItem(item.id, token: token); + ref.invalidate(inventoryProvider); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error))), + ); + } + }, + ), + ); + } +} diff --git a/flutter/lib/features/pantry/data/pantry_providers.dart b/flutter/lib/features/pantry/data/pantry_providers.dart new file mode 100644 index 00000000..edef313a --- /dev/null +++ b/flutter/lib/features/pantry/data/pantry_providers.dart @@ -0,0 +1,28 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; +import '../../auth/data/auth_providers.dart'; +import '../domain/pantry_item.dart'; +import '../domain/pantry_product.dart'; +import 'pantry_repository.dart'; + +final pantryRepositoryProvider = Provider((ref) { + return PantryRepository(ref.watch(apiClientProvider)); +}); + +final pantryProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref.read(pantryRepositoryProvider).fetchPantry(token: token), + ); +}); + +final pantryProductsProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref.read(pantryRepositoryProvider).fetchProducts(token: token), + ); +}); \ No newline at end of file diff --git a/flutter/lib/features/pantry/data/pantry_repository.dart b/flutter/lib/features/pantry/data/pantry_repository.dart new file mode 100644 index 00000000..6878fc9c --- /dev/null +++ b/flutter/lib/features/pantry/data/pantry_repository.dart @@ -0,0 +1,39 @@ +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_paths.dart'; +import '../domain/pantry_item.dart'; +import '../domain/pantry_product.dart'; + +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(); + } + + 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(); + } + + 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); + } + + Future deletePantryItem(int id, {String? token}) async { + await _api.deleteJson(PantryApiPaths.remove(id), token: token); + } +} \ No newline at end of file diff --git a/flutter/lib/features/pantry/domain/pantry_item.dart b/flutter/lib/features/pantry/domain/pantry_item.dart new file mode 100644 index 00000000..76310e49 --- /dev/null +++ b/flutter/lib/features/pantry/domain/pantry_item.dart @@ -0,0 +1,33 @@ +class PantryItem { + final int id; + final int productId; + final String productName; + final String? canonicalName; + final String? category; + + const PantryItem({ + required this.id, + required this.productId, + required this.productName, + this.canonicalName, + this.category, + }); + + String get displayName { + if (canonicalName != null && canonicalName!.trim().isNotEmpty) { + return canonicalName!; + } + return productName; + } + + factory PantryItem.fromJson(Map json) { + final product = json['product'] as Map? ?? {}; + return PantryItem( + id: (json['id'] as num).toInt(), + productId: (json['productId'] as num).toInt(), + productName: (product['name'] ?? '').toString(), + canonicalName: product['canonicalName']?.toString(), + category: product['category']?.toString(), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/pantry/domain/pantry_product.dart b/flutter/lib/features/pantry/domain/pantry_product.dart new file mode 100644 index 00000000..b4ebded3 --- /dev/null +++ b/flutter/lib/features/pantry/domain/pantry_product.dart @@ -0,0 +1,29 @@ +class PantryProduct { + final int id; + final String name; + final String? canonicalName; + final String? category; + + const PantryProduct({ + required this.id, + required this.name, + this.canonicalName, + this.category, + }); + + String get displayName { + if (canonicalName != null && canonicalName!.trim().isNotEmpty) { + return canonicalName!; + } + return name; + } + + factory PantryProduct.fromJson(Map json) { + return PantryProduct( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + canonicalName: json['canonicalName']?.toString(), + category: json['category']?.toString(), + ); + } +} \ 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 new file mode 100644 index 00000000..15ec1bd7 --- /dev/null +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -0,0 +1,389 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/forms/form_options.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'; + +class PantryScreen extends ConsumerStatefulWidget { + const PantryScreen({super.key}); + + @override + ConsumerState createState() => _PantryScreenState(); +} + +class _PantryScreenState extends ConsumerState { + int? _selectedProductId; + bool _isSubmitting = false; + + 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('Lagg "${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: 'Mangd', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: 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( + value: 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 mangd over 0.'; + }); + return; + } + Navigator.pop(ctx, { + 'quantity': quantity, + 'unit': selectedUnit, + 'location': selectedLocation, + }); + }, + child: const Text('Lagg 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, + ); + ref.invalidate(inventoryProvider); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${item.displayName} tillagd i inventarie.')), + ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error))), + ); + } + } + + Future _addItem() async { + final selectedId = _selectedProductId; + if (selectedId == null || _isSubmitting) return; + + setState(() => _isSubmitting = true); + try { + final token = await ref.read(authStateProvider.future); + await ref + .read(pantryRepositoryProvider) + .createPantryItem(selectedId, token: token); + ref.invalidate(pantryProvider); + if (mounted) setState(() => _selectedProductId = null); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error))), + ); + } 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'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final token = await ref.read(authStateProvider.future); + await ref + .read(pantryRepositoryProvider) + .deletePantryItem(item.id, token: token); + ref.invalidate(pantryProvider); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(error))), + ); + } + } + + @override + Widget build(BuildContext context) { + final pantryAsync = ref.watch(pantryProvider); + final productsAsync = ref.watch(pantryProductsProvider); + + if (pantryAsync.isLoading || productsAsync.isLoading) { + return const LoadingStateView(label: 'Laddar baslager...'); + } + + if (pantryAsync.hasError || productsAsync.hasError) { + final error = pantryAsync.error ?? productsAsync.error; + return ErrorStateView( + message: mapErrorToUserMessage(error ?? 'Okant fel'), + onRetry: () { + ref.invalidate(pantryProvider); + ref.invalidate(pantryProductsProvider); + }, + ); + } + + final pantryItems = pantryAsync.valueOrNull ?? const []; + final products = productsAsync.valueOrNull ?? const []; + 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 grouped = >{}; + for (final item in pantryItems) { + final category = (item.category == null || item.category!.isEmpty) + ? 'Ovrigt' + : item.category!; + grouped.putIfAbsent(category, () => []).add(item); + } + final categories = grouped.keys.toList() + ..sort((a, b) { + if (a == 'Ovrigt') return 1; + if (b == 'Ovrigt') return -1; + return a.toLowerCase().compareTo(b.toLowerCase()); + }); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Produkter du alltid raknar med att ha hemma.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + value: _selectedProductId, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Produkt', + border: OutlineInputBorder(), + ), + items: availableProducts + .map( + (product) => DropdownMenuItem( + value: product.id, + child: Text( + product.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: _isSubmitting + ? null + : (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('Lagg 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 ar tomt', + description: 'Lagg 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 tillgangligt i baslager)', + child: IconButton( + onPressed: null, + icon: Icon(Icons.remove_circle_outline), + ), + ), + const Tooltip( + message: 'Redigera (inte tillgangligt i baslager)', + child: IconButton( + onPressed: null, + icon: Icon(Icons.edit_outlined), + ), + ), + IconButton( + tooltip: 'Lagg 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