feat: rename ingredients→products, products→user_products; add barcode/OFF import
- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import '../../features/menu/shopping_list_screen.dart';
|
||||
import '../../features/recipes/recipe_detail_screen.dart';
|
||||
import '../../features/recipes/recipes_screen.dart';
|
||||
import '../../features/profile/profile_screen.dart';
|
||||
import '../../features/products/product_provider.dart';
|
||||
import '../../features/products/user_product_provider.dart';
|
||||
import '../../features/scan/recognition_history_screen.dart';
|
||||
import '../../shared/models/recipe.dart';
|
||||
import '../../shared/models/saved_recipe.dart';
|
||||
@@ -205,7 +205,7 @@ class MainShell extends ConsumerWidget {
|
||||
final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1);
|
||||
|
||||
// Count products expiring soon for the badge.
|
||||
final expiringCount = ref.watch(productsProvider).maybeWhen(
|
||||
final expiringCount = ref.watch(userProductsProvider).maybeWhen(
|
||||
data: (products) => products.where((p) => p.expiringSoon).length,
|
||||
orElse: () => 0,
|
||||
);
|
||||
|
||||
107
client/lib/features/diary/barcode_scan_screen.dart
Normal file
107
client/lib/features/diary/barcode_scan_screen.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import 'food_product_service.dart';
|
||||
import 'product_portion_sheet.dart';
|
||||
|
||||
/// Screen that activates the device camera to scan a barcode.
|
||||
/// On successful scan it looks up the catalog product and shows
|
||||
/// [ProductPortionSheet] to confirm the portion before adding to diary.
|
||||
class BarcodeScanScreen extends ConsumerStatefulWidget {
|
||||
const BarcodeScanScreen({
|
||||
super.key,
|
||||
required this.mealType,
|
||||
required this.date,
|
||||
required this.onAdded,
|
||||
});
|
||||
|
||||
final String mealType;
|
||||
final String date;
|
||||
final VoidCallback onAdded;
|
||||
|
||||
@override
|
||||
ConsumerState<BarcodeScanScreen> createState() => _BarcodeScanScreenState();
|
||||
}
|
||||
|
||||
class _BarcodeScanScreenState extends ConsumerState<BarcodeScanScreen> {
|
||||
bool _scanning = true;
|
||||
|
||||
void _onBarcodeDetected(BarcodeCapture capture) async {
|
||||
if (!_scanning) return;
|
||||
final rawValue = capture.barcodes.firstOrNull?.rawValue;
|
||||
if (rawValue == null) return;
|
||||
|
||||
setState(() => _scanning = false);
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final service = FoodProductService(ref.read(apiClientProvider));
|
||||
final catalogProduct = await service.getByBarcode(rawValue);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (catalogProduct == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.productNotFound)),
|
||||
);
|
||||
setState(() => _scanning = true);
|
||||
return;
|
||||
}
|
||||
|
||||
_showPortionSheet(catalogProduct);
|
||||
}
|
||||
|
||||
void _showPortionSheet(CatalogProduct catalogProduct) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => ProductPortionSheet(
|
||||
catalogProduct: catalogProduct,
|
||||
onConfirm: (portionGrams) => _addToDiary(catalogProduct, portionGrams),
|
||||
),
|
||||
).then((_) {
|
||||
if (mounted) setState(() => _scanning = true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addToDiary(
|
||||
CatalogProduct catalogProduct, double portionGrams) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await ref.read(apiClientProvider).post('/diary', data: {
|
||||
'product_id': catalogProduct.id,
|
||||
'portion_g': portionGrams,
|
||||
'meal_type': widget.mealType,
|
||||
'date': widget.date,
|
||||
'source': 'barcode',
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(catalogProduct.displayName)),
|
||||
);
|
||||
widget.onAdded();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.addFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.scanBarcode)),
|
||||
body: MobileScanner(
|
||||
onDetect: _onBarcodeDetected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
client/lib/features/diary/food_product_service.dart
Normal file
32
client/lib/features/diary/food_product_service.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
|
||||
/// Service for looking up catalog products by barcode or search query.
|
||||
/// Used in the diary flow to log pre-packaged foods.
|
||||
class FoodProductService {
|
||||
const FoodProductService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// Fetches catalog product by barcode. Returns null when not found.
|
||||
Future<CatalogProduct?> getByBarcode(String barcode) async {
|
||||
try {
|
||||
final data = await _client.get('/products/barcode/$barcode');
|
||||
return CatalogProduct.fromJson(data);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Searches catalog products by name query.
|
||||
Future<List<CatalogProduct>> searchProducts(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
final list = await _client.getList(
|
||||
'/products/search',
|
||||
params: {'q': query, 'limit': '10'},
|
||||
);
|
||||
return list
|
||||
.map((e) => CatalogProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
128
client/lib/features/diary/product_portion_sheet.dart
Normal file
128
client/lib/features/diary/product_portion_sheet.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
|
||||
/// Bottom sheet that shows catalog product details and lets the user
|
||||
/// specify a portion weight before adding to diary.
|
||||
class ProductPortionSheet extends ConsumerStatefulWidget {
|
||||
const ProductPortionSheet({
|
||||
super.key,
|
||||
required this.catalogProduct,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final CatalogProduct catalogProduct;
|
||||
final void Function(double portionGrams) onConfirm;
|
||||
|
||||
@override
|
||||
ConsumerState<ProductPortionSheet> createState() =>
|
||||
_ProductPortionSheetState();
|
||||
}
|
||||
|
||||
class _ProductPortionSheetState extends ConsumerState<ProductPortionSheet> {
|
||||
late final _weightController = TextEditingController(text: '100');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_weightController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _confirm() {
|
||||
final grams = double.tryParse(_weightController.text);
|
||||
if (grams == null || grams <= 0) return;
|
||||
widget.onConfirm(grams);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final insets = MediaQuery.viewInsetsOf(context);
|
||||
final catalogProduct = widget.catalogProduct;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
catalogProduct.displayName,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (catalogProduct.categoryName != null)
|
||||
Text(
|
||||
catalogProduct.categoryName!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (catalogProduct.caloriesPer100g != null)
|
||||
_NutritionRow(
|
||||
label: l10n.perHundredG,
|
||||
calories: catalogProduct.caloriesPer100g!,
|
||||
protein: catalogProduct.proteinPer100g,
|
||||
fat: catalogProduct.fatPer100g,
|
||||
carbs: catalogProduct.carbsPer100g,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _weightController,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.portionWeightG,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _confirm,
|
||||
child: Text(l10n.addToJournal),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NutritionRow extends StatelessWidget {
|
||||
const _NutritionRow({
|
||||
required this.label,
|
||||
required this.calories,
|
||||
this.protein,
|
||||
this.fat,
|
||||
this.carbs,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double calories;
|
||||
final double? protein;
|
||||
final double? fat;
|
||||
final double? carbs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
Text(
|
||||
'${calories.toInt()} kcal'
|
||||
'${protein != null ? ' · P ${protein!.toStringAsFixed(1)}g' : ''}'
|
||||
'${fat != null ? ' · F ${fat!.toStringAsFixed(1)}g' : ''}'
|
||||
'${carbs != null ? ' · C ${carbs!.toStringAsFixed(1)}g' : ''}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/locale/unit_provider.dart';
|
||||
import '../../shared/models/ingredient_mapping.dart';
|
||||
import 'product_provider.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import 'user_product_provider.dart';
|
||||
|
||||
class AddProductScreen extends ConsumerStatefulWidget {
|
||||
const AddProductScreen({super.key});
|
||||
@@ -21,11 +21,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
String _unit = 'pcs';
|
||||
String? _category;
|
||||
String? _mappingId;
|
||||
String? _primaryProductId;
|
||||
bool _saving = false;
|
||||
|
||||
// Autocomplete state
|
||||
List<IngredientMapping> _suggestions = [];
|
||||
List<CatalogProduct> _suggestions = [];
|
||||
bool _searching = false;
|
||||
Timer? _debounce;
|
||||
|
||||
@@ -42,7 +42,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
_debounce?.cancel();
|
||||
// Reset mapping if user edits the name after selecting a suggestion
|
||||
setState(() {
|
||||
_mappingId = null;
|
||||
_primaryProductId = null;
|
||||
});
|
||||
|
||||
if (value.trim().isEmpty) {
|
||||
@@ -53,8 +53,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () async {
|
||||
setState(() => _searching = true);
|
||||
try {
|
||||
final service = ref.read(productServiceProvider);
|
||||
final results = await service.searchIngredients(value.trim());
|
||||
final service = ref.read(userProductServiceProvider);
|
||||
final results = await service.searchProducts(value.trim());
|
||||
if (mounted) setState(() => _suggestions = results);
|
||||
} finally {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
@@ -62,16 +62,16 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _selectSuggestion(IngredientMapping mapping) {
|
||||
void _selectSuggestion(CatalogProduct catalogProduct) {
|
||||
setState(() {
|
||||
_nameController.text = mapping.displayName;
|
||||
_mappingId = mapping.id;
|
||||
_category = mapping.category;
|
||||
if (mapping.defaultUnit != null) {
|
||||
_unit = mapping.defaultUnit!;
|
||||
_nameController.text = catalogProduct.displayName;
|
||||
_primaryProductId = catalogProduct.id;
|
||||
_category = catalogProduct.category;
|
||||
if (catalogProduct.defaultUnit != null) {
|
||||
_unit = catalogProduct.defaultUnit!;
|
||||
}
|
||||
if (mapping.storageDays != null) {
|
||||
_daysController.text = mapping.storageDays.toString();
|
||||
if (catalogProduct.storageDays != null) {
|
||||
_daysController.text = catalogProduct.storageDays.toString();
|
||||
}
|
||||
_suggestions = [];
|
||||
});
|
||||
@@ -91,13 +91,13 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(productsProvider.notifier).create(
|
||||
await ref.read(userProductsProvider.notifier).create(
|
||||
name: name,
|
||||
quantity: qty,
|
||||
unit: _unit,
|
||||
category: _category,
|
||||
storageDays: days,
|
||||
mappingId: _mappingId,
|
||||
primaryProductId: _primaryProductId,
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../shared/models/ingredient_mapping.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
|
||||
class ProductService {
|
||||
const ProductService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<List<Product>> getProducts() async {
|
||||
final list = await _client.getList('/products');
|
||||
return list.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Product> createProduct({
|
||||
required String name,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? category,
|
||||
int storageDays = 7,
|
||||
String? mappingId,
|
||||
}) async {
|
||||
final data = await _client.post('/products', data: {
|
||||
'name': name,
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
if (category != null) 'category': category,
|
||||
'storage_days': storageDays,
|
||||
if (mappingId != null) 'mapping_id': mappingId,
|
||||
});
|
||||
return Product.fromJson(data);
|
||||
}
|
||||
|
||||
Future<Product> updateProduct(
|
||||
String id, {
|
||||
String? name,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? category,
|
||||
int? storageDays,
|
||||
}) async {
|
||||
final data = await _client.put('/products/$id', data: {
|
||||
if (name != null) 'name': name,
|
||||
if (quantity != null) 'quantity': quantity,
|
||||
if (unit != null) 'unit': unit,
|
||||
if (category != null) 'category': category,
|
||||
if (storageDays != null) 'storage_days': storageDays,
|
||||
});
|
||||
return Product.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(String id) =>
|
||||
_client.deleteVoid('/products/$id');
|
||||
|
||||
Future<List<IngredientMapping>> searchIngredients(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
final list = await _client.getList(
|
||||
'/ingredients/search',
|
||||
params: {'q': query, 'limit': '10'},
|
||||
);
|
||||
return list
|
||||
.map((e) => IngredientMapping.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/locale/unit_provider.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import 'product_provider.dart';
|
||||
import '../../shared/models/user_product.dart';
|
||||
import 'user_product_provider.dart';
|
||||
|
||||
void _showAddMenu(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
@@ -40,7 +40,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(productsProvider);
|
||||
final state = ref.watch(userProductsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -48,7 +48,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.read(productsProvider.notifier).refresh(),
|
||||
onPressed: () => ref.read(userProductsProvider.notifier).refresh(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -60,7 +60,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
body: state.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => _ErrorView(
|
||||
onRetry: () => ref.read(productsProvider.notifier).refresh(),
|
||||
onRetry: () => ref.read(userProductsProvider.notifier).refresh(),
|
||||
),
|
||||
data: (products) => products.isEmpty
|
||||
? _EmptyState(
|
||||
@@ -79,7 +79,7 @@ class ProductsScreen extends ConsumerWidget {
|
||||
class _ProductList extends ConsumerWidget {
|
||||
const _ProductList({required this.products});
|
||||
|
||||
final List<Product> products;
|
||||
final List<UserProduct> products;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -87,7 +87,7 @@ class _ProductList extends ConsumerWidget {
|
||||
final rest = products.where((p) => !p.expiringSoon).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
|
||||
onRefresh: () => ref.read(userProductsProvider.notifier).refresh(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
children: [
|
||||
@@ -154,7 +154,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
class _ProductTile extends ConsumerWidget {
|
||||
const _ProductTile({required this.product});
|
||||
|
||||
final Product product;
|
||||
final UserProduct product;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -194,7 +194,7 @@ class _ProductTile extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
onDismissed: (_) {
|
||||
ref.read(productsProvider.notifier).delete(product.id);
|
||||
ref.read(userProductsProvider.notifier).delete(product.id);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
@@ -274,7 +274,7 @@ class _ProductTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, Product product) {
|
||||
void _showEditSheet(BuildContext context, WidgetRef ref, UserProduct product) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -290,7 +290,7 @@ class _ProductTile extends ConsumerWidget {
|
||||
class _EditProductSheet extends ConsumerStatefulWidget {
|
||||
const _EditProductSheet({required this.product});
|
||||
|
||||
final Product product;
|
||||
final UserProduct product;
|
||||
|
||||
@override
|
||||
ConsumerState<_EditProductSheet> createState() => _EditProductSheetState();
|
||||
@@ -382,7 +382,7 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
|
||||
try {
|
||||
final qty = double.tryParse(_qtyController.text);
|
||||
final days = int.tryParse(_daysController.text);
|
||||
await ref.read(productsProvider.notifier).update(
|
||||
await ref.read(userProductsProvider.notifier).update(
|
||||
widget.product.id,
|
||||
quantity: qty,
|
||||
unit: _unit,
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_provider.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import 'product_service.dart';
|
||||
import '../../shared/models/user_product.dart';
|
||||
import 'user_product_service.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final productServiceProvider = Provider<ProductService>((ref) {
|
||||
return ProductService(ref.read(apiClientProvider));
|
||||
final userProductServiceProvider = Provider<UserProductService>((ref) {
|
||||
return UserProductService(ref.read(apiClientProvider));
|
||||
});
|
||||
|
||||
final productsProvider =
|
||||
StateNotifierProvider<ProductsNotifier, AsyncValue<List<Product>>>((ref) {
|
||||
final service = ref.read(productServiceProvider);
|
||||
return ProductsNotifier(service);
|
||||
final userProductsProvider =
|
||||
StateNotifierProvider<UserProductsNotifier, AsyncValue<List<UserProduct>>>(
|
||||
(ref) {
|
||||
final service = ref.read(userProductServiceProvider);
|
||||
return UserProductsNotifier(service);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
ProductsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
class UserProductsNotifier
|
||||
extends StateNotifier<AsyncValue<List<UserProduct>>> {
|
||||
UserProductsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
final ProductService _service;
|
||||
final UserProductService _service;
|
||||
|
||||
Future<void> _load() async {
|
||||
state = const AsyncValue.loading();
|
||||
@@ -43,18 +45,18 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
required String unit,
|
||||
String? category,
|
||||
int storageDays = 7,
|
||||
String? mappingId,
|
||||
String? primaryProductId,
|
||||
}) async {
|
||||
final p = await _service.createProduct(
|
||||
final userProduct = await _service.createProduct(
|
||||
name: name,
|
||||
quantity: quantity,
|
||||
unit: unit,
|
||||
category: category,
|
||||
storageDays: storageDays,
|
||||
mappingId: mappingId,
|
||||
primaryProductId: primaryProductId,
|
||||
);
|
||||
state.whenData((products) {
|
||||
final updated = [...products, p]
|
||||
final updated = [...products, userProduct]
|
||||
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
|
||||
state = AsyncValue.data(updated);
|
||||
});
|
||||
@@ -69,7 +71,7 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
String? category,
|
||||
int? storageDays,
|
||||
}) async {
|
||||
final p = await _service.updateProduct(
|
||||
final updated = await _service.updateProduct(
|
||||
id,
|
||||
name: name,
|
||||
quantity: quantity,
|
||||
@@ -78,9 +80,9 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
storageDays: storageDays,
|
||||
);
|
||||
state.whenData((products) {
|
||||
final updated = products.map((e) => e.id == id ? p : e).toList()
|
||||
final updatedList = products.map((e) => e.id == id ? updated : e).toList()
|
||||
..sort((a, b) => a.expiresAt.compareTo(b.expiresAt));
|
||||
state = AsyncValue.data(updated);
|
||||
state = AsyncValue.data(updatedList);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,7 +90,8 @@ class ProductsNotifier extends StateNotifier<AsyncValue<List<Product>>> {
|
||||
Future<void> delete(String id) async {
|
||||
final previous = state;
|
||||
state.whenData((products) {
|
||||
state = AsyncValue.data(products.where((p) => p.id != id).toList());
|
||||
state =
|
||||
AsyncValue.data(products.where((userProduct) => userProduct.id != id).toList());
|
||||
});
|
||||
try {
|
||||
await _service.deleteProduct(id);
|
||||
76
client/lib/features/products/user_product_service.dart
Normal file
76
client/lib/features/products/user_product_service.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../shared/models/product.dart';
|
||||
import '../../shared/models/user_product.dart';
|
||||
|
||||
class UserProductService {
|
||||
const UserProductService(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<List<UserProduct>> getProducts() async {
|
||||
final list = await _client.getList('/user-products');
|
||||
return list
|
||||
.map((e) => UserProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<UserProduct> createProduct({
|
||||
required String name,
|
||||
required double quantity,
|
||||
required String unit,
|
||||
String? category,
|
||||
int storageDays = 7,
|
||||
String? primaryProductId,
|
||||
}) async {
|
||||
final data = await _client.post('/user-products', data: {
|
||||
'name': name,
|
||||
'quantity': quantity,
|
||||
'unit': unit,
|
||||
if (category != null) 'category': category,
|
||||
'storage_days': storageDays,
|
||||
if (primaryProductId != null) 'primary_product_id': primaryProductId,
|
||||
});
|
||||
return UserProduct.fromJson(data);
|
||||
}
|
||||
|
||||
Future<UserProduct> updateProduct(
|
||||
String id, {
|
||||
String? name,
|
||||
double? quantity,
|
||||
String? unit,
|
||||
String? category,
|
||||
int? storageDays,
|
||||
}) async {
|
||||
final data = await _client.put('/user-products/$id', data: {
|
||||
if (name != null) 'name': name,
|
||||
if (quantity != null) 'quantity': quantity,
|
||||
if (unit != null) 'unit': unit,
|
||||
if (category != null) 'category': category,
|
||||
if (storageDays != null) 'storage_days': storageDays,
|
||||
});
|
||||
return UserProduct.fromJson(data);
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(String id) =>
|
||||
_client.deleteVoid('/user-products/$id');
|
||||
|
||||
Future<List<CatalogProduct>> searchProducts(String query) async {
|
||||
if (query.isEmpty) return [];
|
||||
final list = await _client.getList(
|
||||
'/products/search',
|
||||
params: {'q': query, 'limit': '10'},
|
||||
);
|
||||
return list
|
||||
.map((e) => CatalogProduct.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<CatalogProduct?> getByBarcode(String barcode) async {
|
||||
try {
|
||||
final data = await _client.get('/products/barcode/$barcode');
|
||||
return CatalogProduct.fromJson(data);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/locale/unit_provider.dart';
|
||||
import '../products/product_provider.dart';
|
||||
import '../products/user_product_provider.dart';
|
||||
import 'recognition_service.dart';
|
||||
|
||||
/// Editable confirmation screen shown after receipt/products recognition.
|
||||
@@ -31,7 +31,7 @@ class _RecognitionConfirmScreenState
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
category: item.category,
|
||||
mappingId: item.mappingId,
|
||||
primaryProductId: item.primaryProductId,
|
||||
storageDays: item.storageDays,
|
||||
confidence: item.confidence,
|
||||
))
|
||||
@@ -83,13 +83,13 @@ class _RecognitionConfirmScreenState
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
for (final item in _items) {
|
||||
await ref.read(productsProvider.notifier).create(
|
||||
await ref.read(userProductsProvider.notifier).create(
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
category: item.category,
|
||||
storageDays: item.storageDays,
|
||||
mappingId: item.mappingId,
|
||||
primaryProductId: item.primaryProductId,
|
||||
);
|
||||
}
|
||||
if (mounted) {
|
||||
@@ -123,7 +123,7 @@ class _EditableItem {
|
||||
double quantity;
|
||||
String unit;
|
||||
final String category;
|
||||
final String? mappingId;
|
||||
final String? primaryProductId;
|
||||
final int storageDays;
|
||||
final double confidence;
|
||||
|
||||
@@ -132,7 +132,7 @@ class _EditableItem {
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
required this.category,
|
||||
this.mappingId,
|
||||
this.primaryProductId,
|
||||
required this.storageDays,
|
||||
required this.confidence,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ class RecognizedItem {
|
||||
String unit;
|
||||
final String category;
|
||||
final double confidence;
|
||||
final String? mappingId;
|
||||
final String? primaryProductId;
|
||||
final int storageDays;
|
||||
|
||||
RecognizedItem({
|
||||
@@ -31,7 +31,7 @@ class RecognizedItem {
|
||||
required this.unit,
|
||||
required this.category,
|
||||
required this.confidence,
|
||||
this.mappingId,
|
||||
this.primaryProductId,
|
||||
required this.storageDays,
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ class RecognizedItem {
|
||||
unit: json['unit'] as String? ?? 'pcs',
|
||||
category: json['category'] as String? ?? 'other',
|
||||
confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0,
|
||||
mappingId: json['mapping_id'] as String?,
|
||||
primaryProductId: json['mapping_id'] as String?,
|
||||
storageDays: json['storage_days'] as int? ?? 7,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "الموضع {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "جارٍ المعالجة...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "تصوير الإيصال",
|
||||
"photoReceiptSubtitle": "التعرف على جميع المنتجات من الإيصال",
|
||||
"photoProducts": "تصوير المنتجات",
|
||||
"photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور"
|
||||
"photoProductsSubtitle": "الثلاجة، الطاولة، الرف — حتى 3 صور",
|
||||
"addPackagedFood": "إضافة منتج معبأ",
|
||||
"scanBarcode": "مسح الباركود",
|
||||
"portionWeightG": "وزن الحصة (جم)",
|
||||
"productNotFound": "المنتج غير موجود",
|
||||
"enterManually": "أدخل يدوياً",
|
||||
"perHundredG": "لكل 100 جم"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Position {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Verarbeitung...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "Kassenbon fotografieren",
|
||||
"photoReceiptSubtitle": "Alle Produkte vom Kassenbon erkennen",
|
||||
"photoProducts": "Produkte fotografieren",
|
||||
"photoProductsSubtitle": "Kühlschrank, Tisch, Regal — bis zu 3 Fotos"
|
||||
"photoProductsSubtitle": "Kühlschrank, Tisch, Regal — bis zu 3 Fotos",
|
||||
"addPackagedFood": "Verpacktes Lebensmittel hinzufügen",
|
||||
"scanBarcode": "Barcode scannen",
|
||||
"portionWeightG": "Portionsgewicht (g)",
|
||||
"productNotFound": "Produkt nicht gefunden",
|
||||
"enterManually": "Manuell eingeben",
|
||||
"perHundredG": "pro 100 g"
|
||||
}
|
||||
|
||||
@@ -102,5 +102,11 @@
|
||||
"photoReceipt": "Photo of receipt",
|
||||
"photoReceiptSubtitle": "Recognize all items from a receipt",
|
||||
"photoProducts": "Photo of products",
|
||||
"photoProductsSubtitle": "Fridge, table, shelf — up to 3 photos"
|
||||
"photoProductsSubtitle": "Fridge, table, shelf — up to 3 photos",
|
||||
"addPackagedFood": "Add packaged food",
|
||||
"scanBarcode": "Scan barcode",
|
||||
"portionWeightG": "Portion weight (g)",
|
||||
"productNotFound": "Product not found",
|
||||
"enterManually": "Enter manually",
|
||||
"perHundredG": "per 100 g"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Posición {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Procesando...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "Fotografiar recibo",
|
||||
"photoReceiptSubtitle": "Reconocemos todos los productos del recibo",
|
||||
"photoProducts": "Fotografiar productos",
|
||||
"photoProductsSubtitle": "Nevera, mesa, estante — hasta 3 fotos"
|
||||
"photoProductsSubtitle": "Nevera, mesa, estante — hasta 3 fotos",
|
||||
"addPackagedFood": "Agregar alimento envasado",
|
||||
"scanBarcode": "Escanear código de barras",
|
||||
"portionWeightG": "Peso de la porción (g)",
|
||||
"productNotFound": "Producto no encontrado",
|
||||
"enterManually": "Ingresar manualmente",
|
||||
"perHundredG": "por 100 g"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Position {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Traitement...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "Photographier le ticket",
|
||||
"photoReceiptSubtitle": "Reconnaissance de tous les produits du ticket",
|
||||
"photoProducts": "Photographier les produits",
|
||||
"photoProductsSubtitle": "Réfrigérateur, table, étagère — jusqu'à 3 photos"
|
||||
"photoProductsSubtitle": "Réfrigérateur, table, étagère — jusqu'à 3 photos",
|
||||
"addPackagedFood": "Ajouter un aliment emballé",
|
||||
"scanBarcode": "Scanner le code-barres",
|
||||
"portionWeightG": "Poids de la portion (g)",
|
||||
"productNotFound": "Produit introuvable",
|
||||
"enterManually": "Saisir manuellement",
|
||||
"perHundredG": "pour 100 g"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "स्थिति {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "प्रसंस्करण हो रहा है...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "रसीद की फ़ोटो",
|
||||
"photoReceiptSubtitle": "रसीद से सभी उत्पाद पहचानें",
|
||||
"photoProducts": "उत्पादों की फ़ोटो",
|
||||
"photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक"
|
||||
"photoProductsSubtitle": "फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक",
|
||||
"addPackagedFood": "पैकेज्ड फूड जोड़ें",
|
||||
"scanBarcode": "बारकोड स्कैन करें",
|
||||
"portionWeightG": "हिस्से का वजन (ग्राम)",
|
||||
"productNotFound": "उत्पाद नहीं मिला",
|
||||
"enterManually": "मैन्युअल दर्ज करें",
|
||||
"perHundredG": "प्रति 100 ग्राम"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Posizione {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Elaborazione...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "Fotografa scontrino",
|
||||
"photoReceiptSubtitle": "Riconosciamo tutti i prodotti dallo scontrino",
|
||||
"photoProducts": "Fotografa i prodotti",
|
||||
"photoProductsSubtitle": "Frigo, tavolo, scaffale — fino a 3 foto"
|
||||
"photoProductsSubtitle": "Frigo, tavolo, scaffale — fino a 3 foto",
|
||||
"addPackagedFood": "Aggiungi alimento confezionato",
|
||||
"scanBarcode": "Scansiona codice a barre",
|
||||
"portionWeightG": "Peso della porzione (g)",
|
||||
"productNotFound": "Prodotto non trovato",
|
||||
"enterManually": "Inserisci manualmente",
|
||||
"perHundredG": "per 100 g"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "{position}番目",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "処理中...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "レシートを撮影",
|
||||
"photoReceiptSubtitle": "レシートから全商品を認識",
|
||||
"photoProducts": "食品を撮影",
|
||||
"photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚"
|
||||
"photoProductsSubtitle": "冷蔵庫・テーブル・棚 — 最大3枚",
|
||||
"addPackagedFood": "パッケージ食品を追加",
|
||||
"scanBarcode": "バーコードをスキャン",
|
||||
"portionWeightG": "1食分の重さ(g)",
|
||||
"productNotFound": "商品が見つかりません",
|
||||
"enterManually": "手動で入力",
|
||||
"perHundredG": "100gあたり"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "{position}번째",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "처리 중...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "영수증 촬영",
|
||||
"photoReceiptSubtitle": "영수증의 모든 상품 인식",
|
||||
"photoProducts": "식품 촬영",
|
||||
"photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장"
|
||||
"photoProductsSubtitle": "냉장고, 테이블, 선반 — 최대 3장",
|
||||
"addPackagedFood": "포장 식품 추가",
|
||||
"scanBarcode": "바코드 스캔",
|
||||
"portionWeightG": "1회 제공량 (g)",
|
||||
"productNotFound": "제품을 찾을 수 없습니다",
|
||||
"enterManually": "직접 입력",
|
||||
"perHundredG": "100g당"
|
||||
}
|
||||
|
||||
@@ -705,6 +705,42 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Fridge, table, shelf — up to 3 photos'**
|
||||
String get photoProductsSubtitle;
|
||||
|
||||
/// No description provided for @addPackagedFood.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add packaged food'**
|
||||
String get addPackagedFood;
|
||||
|
||||
/// No description provided for @scanBarcode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan barcode'**
|
||||
String get scanBarcode;
|
||||
|
||||
/// No description provided for @portionWeightG.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Portion weight (g)'**
|
||||
String get portionWeightG;
|
||||
|
||||
/// No description provided for @productNotFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Product not found'**
|
||||
String get productNotFound;
|
||||
|
||||
/// No description provided for @enterManually.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter manually'**
|
||||
String get enterManually;
|
||||
|
||||
/// No description provided for @perHundredG.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'per 100 g'**
|
||||
String get perHundredG;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -304,4 +304,22 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'الثلاجة، الطاولة، الرف — حتى 3 صور';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'إضافة منتج معبأ';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'مسح الباركود';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'وزن الحصة (جم)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'المنتج غير موجود';
|
||||
|
||||
@override
|
||||
String get enterManually => 'أدخل يدوياً';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'لكل 100 جم';
|
||||
}
|
||||
|
||||
@@ -306,4 +306,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get photoProductsSubtitle =>
|
||||
'Kühlschrank, Tisch, Regal — bis zu 3 Fotos';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Verpacktes Lebensmittel hinzufügen';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Barcode scannen';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Portionsgewicht (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Produkt nicht gefunden';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Manuell eingeben';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'pro 100 g';
|
||||
}
|
||||
|
||||
@@ -304,4 +304,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'Fridge, table, shelf — up to 3 photos';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Add packaged food';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Scan barcode';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Portion weight (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Product not found';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Enter manually';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'per 100 g';
|
||||
}
|
||||
|
||||
@@ -306,4 +306,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'Nevera, mesa, estante — hasta 3 fotos';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Agregar alimento envasado';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Escanear código de barras';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Peso de la porción (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Producto no encontrado';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Ingresar manualmente';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'por 100 g';
|
||||
}
|
||||
|
||||
@@ -307,4 +307,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get photoProductsSubtitle =>
|
||||
'Réfrigérateur, table, étagère — jusqu\'à 3 photos';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Ajouter un aliment emballé';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Scanner le code-barres';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Poids de la portion (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Produit introuvable';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Saisir manuellement';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'pour 100 g';
|
||||
}
|
||||
|
||||
@@ -305,4 +305,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'फ्रिज, टेबल, शेल्फ — 3 फ़ोटो तक';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'पैकेज्ड फूड जोड़ें';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'बारकोड स्कैन करें';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'हिस्से का वजन (ग्राम)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'उत्पाद नहीं मिला';
|
||||
|
||||
@override
|
||||
String get enterManually => 'मैन्युअल दर्ज करें';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'प्रति 100 ग्राम';
|
||||
}
|
||||
|
||||
@@ -306,4 +306,22 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'Frigo, tavolo, scaffale — fino a 3 foto';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Aggiungi alimento confezionato';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Scansiona codice a barre';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Peso della porzione (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Prodotto non trovato';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Inserisci manualmente';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'per 100 g';
|
||||
}
|
||||
|
||||
@@ -303,4 +303,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => '冷蔵庫・テーブル・棚 — 最大3枚';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'パッケージ食品を追加';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'バーコードをスキャン';
|
||||
|
||||
@override
|
||||
String get portionWeightG => '1食分の重さ(g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => '商品が見つかりません';
|
||||
|
||||
@override
|
||||
String get enterManually => '手動で入力';
|
||||
|
||||
@override
|
||||
String get perHundredG => '100gあたり';
|
||||
}
|
||||
|
||||
@@ -303,4 +303,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => '냉장고, 테이블, 선반 — 최대 3장';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => '포장 식품 추가';
|
||||
|
||||
@override
|
||||
String get scanBarcode => '바코드 스캔';
|
||||
|
||||
@override
|
||||
String get portionWeightG => '1회 제공량 (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => '제품을 찾을 수 없습니다';
|
||||
|
||||
@override
|
||||
String get enterManually => '직접 입력';
|
||||
|
||||
@override
|
||||
String get perHundredG => '100g당';
|
||||
}
|
||||
|
||||
@@ -306,4 +306,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get photoProductsSubtitle =>
|
||||
'Geladeira, mesa, prateleira — até 3 fotos';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Adicionar alimento embalado';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Escanear código de barras';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Peso da porção (g)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Produto não encontrado';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Inserir manualmente';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'por 100 g';
|
||||
}
|
||||
|
||||
@@ -304,4 +304,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => 'Холодильник, стол, полка — до 3 фото';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => 'Добавить готовый продукт';
|
||||
|
||||
@override
|
||||
String get scanBarcode => 'Сканировать штрихкод';
|
||||
|
||||
@override
|
||||
String get portionWeightG => 'Вес порции (г)';
|
||||
|
||||
@override
|
||||
String get productNotFound => 'Продукт не найден';
|
||||
|
||||
@override
|
||||
String get enterManually => 'Ввести вручную';
|
||||
|
||||
@override
|
||||
String get perHundredG => 'на 100 г';
|
||||
}
|
||||
|
||||
@@ -303,4 +303,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get photoProductsSubtitle => '冰箱、桌子、货架 — 最多3张照片';
|
||||
|
||||
@override
|
||||
String get addPackagedFood => '添加包装食品';
|
||||
|
||||
@override
|
||||
String get scanBarcode => '扫描条形码';
|
||||
|
||||
@override
|
||||
String get portionWeightG => '份量(克)';
|
||||
|
||||
@override
|
||||
String get productNotFound => '未找到产品';
|
||||
|
||||
@override
|
||||
String get enterManually => '手动输入';
|
||||
|
||||
@override
|
||||
String get perHundredG => '每100克';
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "Posição {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "Processando...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "Fotografar recibo",
|
||||
"photoReceiptSubtitle": "Reconhecemos todos os produtos do recibo",
|
||||
"photoProducts": "Fotografar produtos",
|
||||
"photoProductsSubtitle": "Geladeira, mesa, prateleira — até 3 fotos"
|
||||
"photoProductsSubtitle": "Geladeira, mesa, prateleira — até 3 fotos",
|
||||
"addPackagedFood": "Adicionar alimento embalado",
|
||||
"scanBarcode": "Escanear código de barras",
|
||||
"portionWeightG": "Peso da porção (g)",
|
||||
"productNotFound": "Produto não encontrado",
|
||||
"enterManually": "Inserir manualmente",
|
||||
"perHundredG": "por 100 g"
|
||||
}
|
||||
|
||||
@@ -102,5 +102,11 @@
|
||||
"photoReceipt": "Сфотографировать чек",
|
||||
"photoReceiptSubtitle": "Распознаем все продукты из чека",
|
||||
"photoProducts": "Сфотографировать продукты",
|
||||
"photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото"
|
||||
"photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото",
|
||||
"addPackagedFood": "Добавить готовый продукт",
|
||||
"scanBarcode": "Сканировать штрихкод",
|
||||
"portionWeightG": "Вес порции (г)",
|
||||
"productNotFound": "Продукт не найден",
|
||||
"enterManually": "Ввести вручную",
|
||||
"perHundredG": "на 100 г"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"queuePosition": "位置 {position}",
|
||||
"@queuePosition": {
|
||||
"placeholders": {
|
||||
"position": { "type": "int" }
|
||||
"position": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"processing": "处理中...",
|
||||
@@ -102,5 +104,11 @@
|
||||
"photoReceipt": "拍摄收据",
|
||||
"photoReceiptSubtitle": "识别收据中的所有商品",
|
||||
"photoProducts": "拍摄食品",
|
||||
"photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片"
|
||||
"photoProductsSubtitle": "冰箱、桌子、货架 — 最多3张照片",
|
||||
"addPackagedFood": "添加包装食品",
|
||||
"scanBarcode": "扫描条形码",
|
||||
"portionWeightG": "份量(克)",
|
||||
"productNotFound": "未找到产品",
|
||||
"enterManually": "手动输入",
|
||||
"perHundredG": "每100克"
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'ingredient_mapping.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class IngredientMapping {
|
||||
final String id;
|
||||
@JsonKey(name: 'canonical_name')
|
||||
final String canonicalName;
|
||||
@JsonKey(name: 'category_name')
|
||||
final String? categoryName;
|
||||
final String? category;
|
||||
@JsonKey(name: 'default_unit')
|
||||
final String? defaultUnit;
|
||||
@JsonKey(name: 'storage_days')
|
||||
final int? storageDays;
|
||||
|
||||
const IngredientMapping({
|
||||
required this.id,
|
||||
required this.canonicalName,
|
||||
this.categoryName,
|
||||
this.category,
|
||||
this.defaultUnit,
|
||||
this.storageDays,
|
||||
});
|
||||
|
||||
/// Display name is the server-resolved canonical name (language-aware from backend).
|
||||
String get displayName => canonicalName;
|
||||
|
||||
factory IngredientMapping.fromJson(Map<String, dynamic> json) =>
|
||||
_$IngredientMappingFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$IngredientMappingToJson(this);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ingredient_mapping.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
IngredientMapping _$IngredientMappingFromJson(Map<String, dynamic> json) =>
|
||||
IngredientMapping(
|
||||
id: json['id'] as String,
|
||||
canonicalName: json['canonical_name'] as String,
|
||||
categoryName: json['category_name'] as String?,
|
||||
category: json['category'] as String?,
|
||||
defaultUnit: json['default_unit'] as String?,
|
||||
storageDays: (json['storage_days'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$IngredientMappingToJson(IngredientMapping instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'canonical_name': instance.canonicalName,
|
||||
'category_name': instance.categoryName,
|
||||
'category': instance.category,
|
||||
'default_unit': instance.defaultUnit,
|
||||
'storage_days': instance.storageDays,
|
||||
};
|
||||
@@ -2,45 +2,48 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'product.g.dart';
|
||||
|
||||
/// Catalog product (shared nutrition database entry).
|
||||
@JsonSerializable()
|
||||
class Product {
|
||||
class CatalogProduct {
|
||||
final String id;
|
||||
@JsonKey(name: 'user_id')
|
||||
final String userId;
|
||||
@JsonKey(name: 'mapping_id')
|
||||
final String? mappingId;
|
||||
final String name;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
@JsonKey(name: 'canonical_name')
|
||||
final String canonicalName;
|
||||
@JsonKey(name: 'category_name')
|
||||
final String? categoryName;
|
||||
final String? category;
|
||||
@JsonKey(name: 'default_unit')
|
||||
final String? defaultUnit;
|
||||
@JsonKey(name: 'storage_days')
|
||||
final int storageDays;
|
||||
@JsonKey(name: 'added_at')
|
||||
final DateTime addedAt;
|
||||
@JsonKey(name: 'expires_at')
|
||||
final DateTime expiresAt;
|
||||
@JsonKey(name: 'days_left')
|
||||
final int daysLeft;
|
||||
@JsonKey(name: 'expiring_soon')
|
||||
final bool expiringSoon;
|
||||
final int? storageDays;
|
||||
final String? barcode;
|
||||
@JsonKey(name: 'calories_per_100g')
|
||||
final double? caloriesPer100g;
|
||||
@JsonKey(name: 'protein_per_100g')
|
||||
final double? proteinPer100g;
|
||||
@JsonKey(name: 'fat_per_100g')
|
||||
final double? fatPer100g;
|
||||
@JsonKey(name: 'carbs_per_100g')
|
||||
final double? carbsPer100g;
|
||||
|
||||
const Product({
|
||||
const CatalogProduct({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.mappingId,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
required this.canonicalName,
|
||||
this.categoryName,
|
||||
this.category,
|
||||
required this.storageDays,
|
||||
required this.addedAt,
|
||||
required this.expiresAt,
|
||||
required this.daysLeft,
|
||||
required this.expiringSoon,
|
||||
this.defaultUnit,
|
||||
this.storageDays,
|
||||
this.barcode,
|
||||
this.caloriesPer100g,
|
||||
this.proteinPer100g,
|
||||
this.fatPer100g,
|
||||
this.carbsPer100g,
|
||||
});
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProductFromJson(json);
|
||||
/// Display name is the server-resolved canonical name (language-aware from backend).
|
||||
String get displayName => canonicalName;
|
||||
|
||||
Map<String, dynamic> toJson() => _$ProductToJson(this);
|
||||
factory CatalogProduct.fromJson(Map<String, dynamic> json) =>
|
||||
_$CatalogProductFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CatalogProductToJson(this);
|
||||
}
|
||||
|
||||
@@ -6,32 +6,32 @@ part of 'product.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Product _$ProductFromJson(Map<String, dynamic> json) => Product(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
mappingId: json['mapping_id'] as String?,
|
||||
name: json['name'] as String,
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unit: json['unit'] as String,
|
||||
category: json['category'] as String?,
|
||||
storageDays: (json['storage_days'] as num).toInt(),
|
||||
addedAt: DateTime.parse(json['added_at'] as String),
|
||||
expiresAt: DateTime.parse(json['expires_at'] as String),
|
||||
daysLeft: (json['days_left'] as num).toInt(),
|
||||
expiringSoon: json['expiring_soon'] as bool,
|
||||
);
|
||||
CatalogProduct _$CatalogProductFromJson(Map<String, dynamic> json) =>
|
||||
CatalogProduct(
|
||||
id: json['id'] as String,
|
||||
canonicalName: json['canonical_name'] as String,
|
||||
categoryName: json['category_name'] as String?,
|
||||
category: json['category'] as String?,
|
||||
defaultUnit: json['default_unit'] as String?,
|
||||
storageDays: (json['storage_days'] as num?)?.toInt(),
|
||||
barcode: json['barcode'] as String?,
|
||||
caloriesPer100g: (json['calories_per_100g'] as num?)?.toDouble(),
|
||||
proteinPer100g: (json['protein_per_100g'] as num?)?.toDouble(),
|
||||
fatPer100g: (json['fat_per_100g'] as num?)?.toDouble(),
|
||||
carbsPer100g: (json['carbs_per_100g'] as num?)?.toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProductToJson(Product instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'user_id': instance.userId,
|
||||
'mapping_id': instance.mappingId,
|
||||
'name': instance.name,
|
||||
'quantity': instance.quantity,
|
||||
'unit': instance.unit,
|
||||
'category': instance.category,
|
||||
'storage_days': instance.storageDays,
|
||||
'added_at': instance.addedAt.toIso8601String(),
|
||||
'expires_at': instance.expiresAt.toIso8601String(),
|
||||
'days_left': instance.daysLeft,
|
||||
'expiring_soon': instance.expiringSoon,
|
||||
};
|
||||
Map<String, dynamic> _$CatalogProductToJson(CatalogProduct instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'canonical_name': instance.canonicalName,
|
||||
'category_name': instance.categoryName,
|
||||
'category': instance.category,
|
||||
'default_unit': instance.defaultUnit,
|
||||
'storage_days': instance.storageDays,
|
||||
'barcode': instance.barcode,
|
||||
'calories_per_100g': instance.caloriesPer100g,
|
||||
'protein_per_100g': instance.proteinPer100g,
|
||||
'fat_per_100g': instance.fatPer100g,
|
||||
'carbs_per_100g': instance.carbsPer100g,
|
||||
};
|
||||
|
||||
46
client/lib/shared/models/user_product.dart
Normal file
46
client/lib/shared/models/user_product.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'user_product.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class UserProduct {
|
||||
final String id;
|
||||
@JsonKey(name: 'user_id')
|
||||
final String userId;
|
||||
@JsonKey(name: 'primary_product_id')
|
||||
final String? primaryProductId;
|
||||
final String name;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? category;
|
||||
@JsonKey(name: 'storage_days')
|
||||
final int storageDays;
|
||||
@JsonKey(name: 'added_at')
|
||||
final DateTime addedAt;
|
||||
@JsonKey(name: 'expires_at')
|
||||
final DateTime expiresAt;
|
||||
@JsonKey(name: 'days_left')
|
||||
final int daysLeft;
|
||||
@JsonKey(name: 'expiring_soon')
|
||||
final bool expiringSoon;
|
||||
|
||||
const UserProduct({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
this.primaryProductId,
|
||||
required this.name,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.category,
|
||||
required this.storageDays,
|
||||
required this.addedAt,
|
||||
required this.expiresAt,
|
||||
required this.daysLeft,
|
||||
required this.expiringSoon,
|
||||
});
|
||||
|
||||
factory UserProduct.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserProductFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$UserProductToJson(this);
|
||||
}
|
||||
38
client/lib/shared/models/user_product.g.dart
Normal file
38
client/lib/shared/models/user_product.g.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_product.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
UserProduct _$UserProductFromJson(Map<String, dynamic> json) => UserProduct(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
primaryProductId: json['primary_product_id'] as String?,
|
||||
name: json['name'] as String,
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unit: json['unit'] as String,
|
||||
category: json['category'] as String?,
|
||||
storageDays: (json['storage_days'] as num).toInt(),
|
||||
addedAt: DateTime.parse(json['added_at'] as String),
|
||||
expiresAt: DateTime.parse(json['expires_at'] as String),
|
||||
daysLeft: (json['days_left'] as num).toInt(),
|
||||
expiringSoon: json['expiring_soon'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserProductToJson(UserProduct instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'user_id': instance.userId,
|
||||
'primary_product_id': instance.primaryProductId,
|
||||
'name': instance.name,
|
||||
'quantity': instance.quantity,
|
||||
'unit': instance.unit,
|
||||
'category': instance.category,
|
||||
'storage_days': instance.storageDays,
|
||||
'added_at': instance.addedAt.toIso8601String(),
|
||||
'expires_at': instance.expiresAt.toIso8601String(),
|
||||
'days_left': instance.daysLeft,
|
||||
'expiring_soon': instance.expiringSoon,
|
||||
};
|
||||
Reference in New Issue
Block a user