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

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