From e7251fd94c8d46b6032ffeaedf84d82f47271d12 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 6 May 2026 11:54:56 +0200 Subject: [PATCH] feat: add location field to PantryItem model and update related functionality Co-authored-by: Copilot --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + .../src/pantry/dto/create-pantry-item.dto.ts | 7 +- backend/src/pantry/pantry.service.ts | 6 +- .../presentation/create_inventory_screen.dart | 115 +++++++++++++----- .../pantry/data/pantry_repository.dart | 14 ++- .../features/pantry/domain/pantry_item.dart | 3 + .../pantry/presentation/pantry_screen.dart | 3 + 8 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 backend/prisma/migrations/20260506180000_add_pantry_location/migration.sql diff --git a/backend/prisma/migrations/20260506180000_add_pantry_location/migration.sql b/backend/prisma/migrations/20260506180000_add_pantry_location/migration.sql new file mode 100644 index 00000000..338b5072 --- /dev/null +++ b/backend/prisma/migrations/20260506180000_add_pantry_location/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `PantryItem` +ADD COLUMN `location` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bfa0ca12..72b16cd0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -172,6 +172,7 @@ model PantryItem { id Int @id @default(autoincrement()) userId Int productId Int + location String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) diff --git a/backend/src/pantry/dto/create-pantry-item.dto.ts b/backend/src/pantry/dto/create-pantry-item.dto.ts index 9f1361b0..2afa2913 100644 --- a/backend/src/pantry/dto/create-pantry-item.dto.ts +++ b/backend/src/pantry/dto/create-pantry-item.dto.ts @@ -1,7 +1,12 @@ -import { IsInt, IsPositive } from 'class-validator'; +import { IsInt, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator'; export class CreatePantryItemDto { @IsInt() @IsPositive() productId: number; + + @IsOptional() + @IsString() + @MaxLength(50) + location?: string; } diff --git a/backend/src/pantry/pantry.service.ts b/backend/src/pantry/pantry.service.ts index 5f942e36..d1bb5102 100644 --- a/backend/src/pantry/pantry.service.ts +++ b/backend/src/pantry/pantry.service.ts @@ -33,7 +33,11 @@ export class PantryService { } return this.prisma.pantryItem.create({ - data: { userId, productId: data.productId }, + data: { + userId, + productId: data.productId, + location: data.location?.trim() || null, + }, include: { product: true }, }); } diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index c0791fee..a9129bc6 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -10,7 +10,9 @@ import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/product_picker_field.dart'; import '../../auth/data/auth_providers.dart'; +import '../../pantry/data/pantry_providers.dart'; import '../data/inventory_providers.dart'; +import '../../import/data/receipt_import_session.dart' show ImportDestination; class CreateInventoryScreen extends ConsumerStatefulWidget { const CreateInventoryScreen({super.key}); @@ -36,6 +38,7 @@ class _CreateInventoryScreenState DateTime? _bestBeforeDate; bool _opened = false; bool _saving = false; + ImportDestination _destination = ImportDestination.inventory; @override void initState() { @@ -101,13 +104,40 @@ class _CreateInventoryScreenState } Future _save() async { - if (!_formKey.currentState!.validate()) return; if (_selectedProductId == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.inventorySelectProduct)), ); return; } + + if (_destination == ImportDestination.pantry) { + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + await ref + .read(pantryRepositoryProvider) + .createPantryItem( + _selectedProductId!, + token: token, + location: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + ); + ref.invalidate(pantryProvider); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + return; + } + + if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); try { final token = await ref.read(authStateProvider.future); @@ -163,6 +193,8 @@ class _CreateInventoryScreenState ) .toList(); + final isInventory = _destination == ImportDestination.inventory; + return Scaffold( appBar: AppBar(title: Text(context.l10n.inventoryCreateTitle)), body: Form( @@ -170,6 +202,25 @@ class _CreateInventoryScreenState child: ListView( padding: const EdgeInsets.all(16), children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: ImportDestination.inventory, + label: Text('Inventarie'), + icon: Icon(Icons.inventory_2_outlined), + ), + ButtonSegment( + value: ImportDestination.pantry, + label: Text('Skafferi'), + icon: Icon(Icons.kitchen_outlined), + ), + ], + selected: {_destination}, + onSelectionChanged: _saving + ? null + : (selected) => setState(() => _destination = selected.first), + ), + const SizedBox(height: 16), ProductPickerField( products: productOptions, value: _selectedProductId, @@ -179,7 +230,31 @@ class _CreateInventoryScreenState onChanged: (value) => setState(() => _selectedProductId = value), ), const SizedBox(height: 12), - Row( + DropdownButtonFormField( + initialValue: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.locationOptionalLabel, + border: const OutlineInputBorder(), + ), + items: inventoryLocationOptions + .map( + (location) => DropdownMenuItem( + value: location, + child: Text(location), + ), + ) + .toList(), + onChanged: _saving + ? null + : (value) => setState(() { + _locationController.text = value ?? ''; + }), + ), + if (isInventory) const SizedBox(height: 12), + if (isInventory) Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( @@ -232,32 +307,8 @@ class _CreateInventoryScreenState ), ], ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _locationController.text.trim().isEmpty - ? null - : _locationController.text.trim(), - isExpanded: true, - decoration: InputDecoration( - labelText: context.l10n.locationOptionalLabel, - border: const OutlineInputBorder(), - ), - items: inventoryLocationOptions - .map( - (location) => DropdownMenuItem( - value: location, - child: Text(location), - ), - ) - .toList(), - onChanged: _saving - ? null - : (value) => setState(() { - _locationController.text = value ?? ''; - }), - ), - const SizedBox(height: 12), - TextFormField( + if (isInventory) const SizedBox(height: 12), + if (isInventory) TextFormField( controller: _brandController, decoration: InputDecoration( labelText: context.l10n.brandOptionalLabel, @@ -265,8 +316,8 @@ class _CreateInventoryScreenState ), enabled: !_saving, ), - const SizedBox(height: 12), - Row( + if (isInventory) const SizedBox(height: 12), + if (isInventory) Row( children: [ Expanded( child: OutlinedButton.icon( @@ -291,7 +342,7 @@ class _CreateInventoryScreenState ), ], ), - CheckboxListTile( + if (isInventory) CheckboxListTile( title: Text(context.l10n.openedLabel), value: _opened, onChanged: @@ -299,7 +350,7 @@ class _CreateInventoryScreenState controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), - TextFormField( + if (isInventory) TextFormField( controller: _commentController, decoration: InputDecoration( labelText: context.l10n.commentOptionalLabel, diff --git a/flutter/lib/features/pantry/data/pantry_repository.dart b/flutter/lib/features/pantry/data/pantry_repository.dart index 8e939530..6f0b2848 100644 --- a/flutter/lib/features/pantry/data/pantry_repository.dart +++ b/flutter/lib/features/pantry/data/pantry_repository.dart @@ -27,7 +27,7 @@ class PantryRepository { Future> fetchProducts({String? token}) async { try { - final data = await _api.getJson(ProductApiPaths.list, token: token); + final data = await _api.getJson(ProductApiPaths.mine, token: token); final list = data as List; _logger.info('Fetched ${list.length} products'); return list @@ -39,11 +39,19 @@ class PantryRepository { } } - Future createPantryItem(int productId, {String? token}) async { + Future createPantryItem( + int productId, { + String? token, + String? location, + }) async { try { final data = await _api.postJson( PantryApiPaths.list, - body: {'productId': productId}, + body: { + 'productId': productId, + if (location != null && location.trim().isNotEmpty) + 'location': location.trim(), + }, token: token, ); _logger.info('Created pantry item for product ID: $productId'); diff --git a/flutter/lib/features/pantry/domain/pantry_item.dart b/flutter/lib/features/pantry/domain/pantry_item.dart index ebe7ae9d..45f528d2 100644 --- a/flutter/lib/features/pantry/domain/pantry_item.dart +++ b/flutter/lib/features/pantry/domain/pantry_item.dart @@ -5,6 +5,7 @@ class PantryItem { final String? canonicalName; final String? category; final int? categoryId; + final String? location; const PantryItem({ required this.id, @@ -13,6 +14,7 @@ class PantryItem { this.canonicalName, this.category, this.categoryId, + this.location, }); String get displayName { @@ -31,6 +33,7 @@ class PantryItem { canonicalName: product['canonicalName']?.toString(), category: product['category']?.toString(), categoryId: (product['categoryId'] as num?)?.toInt(), + location: json['location']?.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 index c86f6e23..f17fa381 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -374,6 +374,9 @@ class _PantryScreenState extends ConsumerState { margin: const EdgeInsets.only(bottom: 8), child: ListTile( title: Text(item.displayName), + subtitle: item.location == null || item.location!.trim().isEmpty + ? null + : Text('Plats: ${item.location}'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [