feat: add location field to PantryItem model and update related functionality
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `PantryItem`
|
||||
ADD COLUMN `location` VARCHAR(191) NULL;
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> _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<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(
|
||||
products: productOptions,
|
||||
value: _selectedProductId,
|
||||
@@ -179,7 +230,31 @@ class _CreateInventoryScreenState
|
||||
onChanged: (value) => setState(() => _selectedProductId = value),
|
||||
),
|
||||
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,
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -232,32 +307,8 @@ class _CreateInventoryScreenState
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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 ?? '';
|
||||
}),
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -27,7 +27,7 @@ class PantryRepository {
|
||||
|
||||
Future<List<PantryProduct>> 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<dynamic>;
|
||||
_logger.info('Fetched ${list.length} products');
|
||||
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 {
|
||||
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');
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
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: [
|
||||
|
||||
Reference in New Issue
Block a user