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

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