feat: add location field to PantryItem model and update related functionality
Test Suite / test (24.15.0) (push) Has been cancelled

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-06 11:54:56 +02:00
parent 63d249b0a8
commit e7251fd94c
8 changed files with 114 additions and 37 deletions
@@ -0,0 +1,2 @@
ALTER TABLE `PantryItem`
ADD COLUMN `location` VARCHAR(191) NULL;
+1
View File
@@ -172,6 +172,7 @@ model PantryItem {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
productId Int productId Int
location String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1,7 +1,12 @@
import { IsInt, IsPositive } from 'class-validator'; import { IsInt, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
export class CreatePantryItemDto { export class CreatePantryItemDto {
@IsInt() @IsInt()
@IsPositive() @IsPositive()
productId: number; productId: number;
@IsOptional()
@IsString()
@MaxLength(50)
location?: string;
} }
+5 -1
View File
@@ -33,7 +33,11 @@ export class PantryService {
} }
return this.prisma.pantryItem.create({ return this.prisma.pantryItem.create({
data: { userId, productId: data.productId }, data: {
userId,
productId: data.productId,
location: data.location?.trim() || null,
},
include: { product: true }, include: { product: true },
}); });
} }
@@ -10,7 +10,9 @@ import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/ui/product_picker_field.dart'; import '../../../core/ui/product_picker_field.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../../pantry/data/pantry_providers.dart';
import '../data/inventory_providers.dart'; import '../data/inventory_providers.dart';
import '../../import/data/receipt_import_session.dart' show ImportDestination;
class CreateInventoryScreen extends ConsumerStatefulWidget { class CreateInventoryScreen extends ConsumerStatefulWidget {
const CreateInventoryScreen({super.key}); const CreateInventoryScreen({super.key});
@@ -36,6 +38,7 @@ class _CreateInventoryScreenState
DateTime? _bestBeforeDate; DateTime? _bestBeforeDate;
bool _opened = false; bool _opened = false;
bool _saving = false; bool _saving = false;
ImportDestination _destination = ImportDestination.inventory;
@override @override
void initState() { void initState() {
@@ -101,13 +104,40 @@ class _CreateInventoryScreenState
} }
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_selectedProductId == null) { if (_selectedProductId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.inventorySelectProduct)), SnackBar(content: Text(context.l10n.inventorySelectProduct)),
); );
return; 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); setState(() => _saving = true);
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
@@ -163,6 +193,8 @@ class _CreateInventoryScreenState
) )
.toList(); .toList();
final isInventory = _destination == ImportDestination.inventory;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(context.l10n.inventoryCreateTitle)), appBar: AppBar(title: Text(context.l10n.inventoryCreateTitle)),
body: Form( body: Form(
@@ -170,6 +202,25 @@ class _CreateInventoryScreenState
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
SegmentedButton<ImportDestination>(
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( ProductPickerField(
products: productOptions, products: productOptions,
value: _selectedProductId, value: _selectedProductId,
@@ -179,7 +230,31 @@ class _CreateInventoryScreenState
onChanged: (value) => setState(() => _selectedProductId = value), onChanged: (value) => setState(() => _selectedProductId = value),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( DropdownButtonFormField<String>(
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<String>(
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
@@ -232,32 +307,8 @@ class _CreateInventoryScreenState
), ),
], ],
), ),
const SizedBox(height: 12), if (isInventory) const SizedBox(height: 12),
DropdownButtonFormField<String>( if (isInventory) TextFormField(
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<String>(
value: location,
child: Text(location),
),
)
.toList(),
onChanged: _saving
? null
: (value) => setState(() {
_locationController.text = value ?? '';
}),
),
const SizedBox(height: 12),
TextFormField(
controller: _brandController, controller: _brandController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.brandOptionalLabel, labelText: context.l10n.brandOptionalLabel,
@@ -265,8 +316,8 @@ class _CreateInventoryScreenState
), ),
enabled: !_saving, enabled: !_saving,
), ),
const SizedBox(height: 12), if (isInventory) const SizedBox(height: 12),
Row( if (isInventory) Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -291,7 +342,7 @@ class _CreateInventoryScreenState
), ),
], ],
), ),
CheckboxListTile( if (isInventory) CheckboxListTile(
title: Text(context.l10n.openedLabel), title: Text(context.l10n.openedLabel),
value: _opened, value: _opened,
onChanged: onChanged:
@@ -299,7 +350,7 @@ class _CreateInventoryScreenState
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
TextFormField( if (isInventory) TextFormField(
controller: _commentController, controller: _commentController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.commentOptionalLabel, labelText: context.l10n.commentOptionalLabel,
@@ -27,7 +27,7 @@ class PantryRepository {
Future<List<PantryProduct>> fetchProducts({String? token}) async { Future<List<PantryProduct>> fetchProducts({String? token}) async {
try { try {
final data = await _api.getJson(ProductApiPaths.list, token: token); final data = await _api.getJson(ProductApiPaths.mine, token: token);
final list = data as List<dynamic>; final list = data as List<dynamic>;
_logger.info('Fetched ${list.length} products'); _logger.info('Fetched ${list.length} products');
return list return list
@@ -39,11 +39,19 @@ class PantryRepository {
} }
} }
Future<PantryItem> createPantryItem(int productId, {String? token}) async { Future<PantryItem> createPantryItem(
int productId, {
String? token,
String? location,
}) async {
try { try {
final data = await _api.postJson( final data = await _api.postJson(
PantryApiPaths.list, PantryApiPaths.list,
body: {'productId': productId}, body: {
'productId': productId,
if (location != null && location.trim().isNotEmpty)
'location': location.trim(),
},
token: token, token: token,
); );
_logger.info('Created pantry item for product ID: $productId'); _logger.info('Created pantry item for product ID: $productId');
@@ -5,6 +5,7 @@ class PantryItem {
final String? canonicalName; final String? canonicalName;
final String? category; final String? category;
final int? categoryId; final int? categoryId;
final String? location;
const PantryItem({ const PantryItem({
required this.id, required this.id,
@@ -13,6 +14,7 @@ class PantryItem {
this.canonicalName, this.canonicalName,
this.category, this.category,
this.categoryId, this.categoryId,
this.location,
}); });
String get displayName { String get displayName {
@@ -31,6 +33,7 @@ class PantryItem {
canonicalName: product['canonicalName']?.toString(), canonicalName: product['canonicalName']?.toString(),
category: product['category']?.toString(), category: product['category']?.toString(),
categoryId: (product['categoryId'] as num?)?.toInt(), categoryId: (product['categoryId'] as num?)?.toInt(),
location: json['location']?.toString(),
); );
} }
} }
@@ -374,6 +374,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
title: Text(item.displayName), title: Text(item.displayName),
subtitle: item.location == null || item.location!.trim().isEmpty
? null
: Text('Plats: ${item.location}'),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [