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