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())
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user