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:
dbastrikin
2026-03-21 12:45:48 +02:00
parent 6861e5e754
commit 205edbdade
72 changed files with 2588 additions and 1444 deletions

View File

@@ -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,
);

View 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,
),
);
}
}

View 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();
}
}

View 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,
),
),
],
);
}
}

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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);

View 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;
}
}
}

View File

@@ -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,
});

View File

@@ -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,
);
}

View File

@@ -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 جم"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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 ग्राम"
}

View File

@@ -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"
}

View File

@@ -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あたり"
}

View File

@@ -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당"
}

View File

@@ -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

View File

@@ -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 جم';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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 ग्राम';
}

View File

@@ -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';
}

View File

@@ -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あたり';
}

View File

@@ -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당';
}

View File

@@ -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';
}

View File

@@ -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 г';
}

View File

@@ -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克';
}

View File

@@ -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"
}

View File

@@ -102,5 +102,11 @@
"photoReceipt": "Сфотографировать чек",
"photoReceiptSubtitle": "Распознаем все продукты из чека",
"photoProducts": "Сфотографировать продукты",
"photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото"
"photoProductsSubtitle": "Холодильник, стол, полка — до 3 фото",
"addPackagedFood": "Добавить готовый продукт",
"scanBarcode": "Сканировать штрихкод",
"portionWeightG": "Вес порции (г)",
"productNotFound": "Продукт не найден",
"enterManually": "Ввести вручную",
"perHundredG": "на 100 г"
}

View File

@@ -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克"
}

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -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);
}

View File

@@ -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,
};

View 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);
}

View 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,
};

View File

@@ -717,6 +717,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
mockito:
dependency: "direct dev"
description:

View File

@@ -43,6 +43,7 @@ dependencies:
# Camera / gallery
image_picker: ^1.1.0
mobile_scanner: ^6.0.0
dev_dependencies:
flutter_test: