diff --git a/backend/cmd/server/init.go b/backend/cmd/server/init.go index 2490e7a..0b53184 100644 --- a/backend/cmd/server/init.go +++ b/backend/cmd/server/init.go @@ -45,7 +45,7 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { productRepository := product.NewRepository(pool) openFoodFactsClient := product.NewOpenFoodFacts() productHandler := product.NewHandler(productRepository, openFoodFactsClient) - userProductHandler := userproduct.NewHandler(userProductRepository) + userProductHandler := userproduct.NewHandler(userProductRepository, productRepository) // Kafka producer kafkaProducer, kafkaProducerError := newKafkaProducer(appConfig) diff --git a/backend/internal/domain/userproduct/entity.go b/backend/internal/domain/userproduct/entity.go index f71beb9..025aec8 100644 --- a/backend/internal/domain/userproduct/entity.go +++ b/backend/internal/domain/userproduct/entity.go @@ -28,6 +28,14 @@ type CreateRequest struct { Unit string `json:"unit"` Category *string `json:"category"` StorageDays int `json:"storage_days"` + // Optional nutrition fields per 100g. + // When provided and primary_product_id is absent, the backend upserts a catalog + // product and links it automatically. + CaloriesPer100g *float64 `json:"calories_per_100g"` + ProteinPer100g *float64 `json:"protein_per_100g"` + FatPer100g *float64 `json:"fat_per_100g"` + CarbsPer100g *float64 `json:"carbs_per_100g"` + FiberPer100g *float64 `json:"fiber_per_100g"` } // UpdateRequest is the body for PUT /user-products/{id}. diff --git a/backend/internal/domain/userproduct/handler.go b/backend/internal/domain/userproduct/handler.go index dc6d25d..95ba977 100644 --- a/backend/internal/domain/userproduct/handler.go +++ b/backend/internal/domain/userproduct/handler.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" + "github.com/food-ai/backend/internal/domain/product" "github.com/food-ai/backend/internal/infra/middleware" "github.com/go-chi/chi/v5" ) @@ -18,16 +19,24 @@ type UserProductRepository interface { BatchCreate(ctx context.Context, userID string, items []CreateRequest) ([]*UserProduct, error) Update(ctx context.Context, id, userID string, req UpdateRequest) (*UserProduct, error) Delete(ctx context.Context, id, userID string) error + DeleteAll(ctx context.Context, userID string) error +} + +// ProductUpserter creates or updates a catalog product entry. +// Implemented by product.Repository — defined here to avoid import cycles. +type ProductUpserter interface { + Upsert(ctx context.Context, catalogProduct *product.Product) (*product.Product, error) } // Handler handles /user-products HTTP requests. type Handler struct { - repo UserProductRepository + repo UserProductRepository + productUpserter ProductUpserter } // NewHandler creates a new Handler. -func NewHandler(repo UserProductRepository) *Handler { - return &Handler{repo: repo} +func NewHandler(repo UserProductRepository, productUpserter ProductUpserter) *Handler { + return &Handler{repo: repo, productUpserter: productUpserter} } // List handles GET /user-products. @@ -47,7 +56,8 @@ func (handler *Handler) List(responseWriter http.ResponseWriter, request *http.R // Create handles POST /user-products. func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http.Request) { - userID := middleware.UserIDFromCtx(request.Context()) + requestContext := request.Context() + userID := middleware.UserIDFromCtx(requestContext) var req CreateRequest if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil { writeErrorJSON(responseWriter, request, http.StatusBadRequest, "invalid request body") @@ -58,9 +68,40 @@ func (handler *Handler) Create(responseWriter http.ResponseWriter, request *http return } - userProduct, createError := handler.repo.Create(request.Context(), userID, req) + // Resolve effective primary_product_id (accept legacy mapping_id alias). + primaryProductID := req.PrimaryProductID + if primaryProductID == nil { + primaryProductID = req.MappingID + } + + // When nutrition data is provided without an existing catalog link, upsert a catalog + // product so the nutrition info is not silently discarded. + hasNutrition := req.CaloriesPer100g != nil || req.ProteinPer100g != nil || + req.FatPer100g != nil || req.CarbsPer100g != nil || req.FiberPer100g != nil + if hasNutrition && primaryProductID == nil { + catalogProduct := &product.Product{ + CanonicalName: req.Name, + Category: req.Category, + DefaultUnit: &req.Unit, + CaloriesPer100g: req.CaloriesPer100g, + ProteinPer100g: req.ProteinPer100g, + FatPer100g: req.FatPer100g, + CarbsPer100g: req.CarbsPer100g, + FiberPer100g: req.FiberPer100g, + StorageDays: &req.StorageDays, + } + savedProduct, upsertError := handler.productUpserter.Upsert(requestContext, catalogProduct) + if upsertError != nil { + slog.WarnContext(requestContext, "upsert catalog product on manual add", "name", req.Name, "err", upsertError) + // Graceful degradation: continue without catalog link. + } else { + req.PrimaryProductID = &savedProduct.ID + } + } + + userProduct, createError := handler.repo.Create(requestContext, userID, req) if createError != nil { - slog.ErrorContext(request.Context(), "create user product", "user_id", userID, "err", createError) + slog.ErrorContext(requestContext, "create user product", "user_id", userID, "err", createError) writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create user product") return } @@ -113,6 +154,17 @@ func (handler *Handler) Update(responseWriter http.ResponseWriter, request *http writeJSON(responseWriter, http.StatusOK, userProduct) } +// DeleteAll handles DELETE /user-products. +func (handler *Handler) DeleteAll(responseWriter http.ResponseWriter, request *http.Request) { + userID := middleware.UserIDFromCtx(request.Context()) + if deleteError := handler.repo.DeleteAll(request.Context(), userID); deleteError != nil { + slog.ErrorContext(request.Context(), "delete all user products", "user_id", userID, "err", deleteError) + writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to delete all user products") + return + } + responseWriter.WriteHeader(http.StatusNoContent) +} + // Delete handles DELETE /user-products/{id}. func (handler *Handler) Delete(responseWriter http.ResponseWriter, request *http.Request) { userID := middleware.UserIDFromCtx(request.Context()) diff --git a/backend/internal/domain/userproduct/repository.go b/backend/internal/domain/userproduct/repository.go index 4680ccb..aa46d85 100644 --- a/backend/internal/domain/userproduct/repository.go +++ b/backend/internal/domain/userproduct/repository.go @@ -106,6 +106,16 @@ func (r *Repository) Update(requestContext context.Context, id, userID string, r return userProduct, scanError } +// DeleteAll removes all user products for the given user. +func (r *Repository) DeleteAll(requestContext context.Context, userID string) error { + _, execError := r.pool.Exec(requestContext, + `DELETE FROM user_products WHERE user_id = $1`, userID) + if execError != nil { + return fmt.Errorf("delete all user products: %w", execError) + } + return nil +} + // Delete removes a user product. Returns ErrNotFound if it does not exist or belongs to a different user. func (r *Repository) Delete(requestContext context.Context, id, userID string) error { tag, execError := r.pool.Exec(requestContext, diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 7f7ce59..d6e9ccb 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -87,6 +87,7 @@ func NewRouter( r.Post("/", userProductHandler.Create) r.Post("/batch", userProductHandler.BatchCreate) r.Put("/{id}", userProductHandler.Update) + r.Delete("/", userProductHandler.DeleteAll) r.Delete("/{id}", userProductHandler.Delete) }) diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 15440bb..d22c8f0 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -13,6 +13,7 @@ import '../../features/profile/profile_provider.dart'; import '../../shared/models/user.dart'; import '../../features/products/products_screen.dart'; import '../../features/products/add_product_screen.dart'; +import '../../features/products/product_search_screen.dart'; import '../../features/products/product_job_history_screen.dart'; import '../../features/scan/product_job_watch_screen.dart'; import '../../features/scan/scan_screen.dart'; @@ -107,6 +108,11 @@ final routerProvider = Provider((ref) { path: '/products/add', builder: (_, __) => const AddProductScreen(), ), + // Product search — search catalog before falling back to manual add. + GoRoute( + path: '/products/search', + builder: (_, __) => const ProductSearchScreen(), + ), // Shopping list — full-screen, no bottom nav. GoRoute( path: '/menu/shopping-list', diff --git a/client/lib/features/products/add_product_screen.dart b/client/lib/features/products/add_product_screen.dart index 7fae7eb..5008d0a 100644 --- a/client/lib/features/products/add_product_screen.dart +++ b/client/lib/features/products/add_product_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/locale/unit_provider.dart'; +import '../../l10n/app_localizations.dart'; import '../../shared/models/product.dart'; import 'user_product_provider.dart'; @@ -23,17 +24,30 @@ class _AddProductScreenState extends ConsumerState { String? _category; String? _primaryProductId; bool _saving = false; + bool _showNutrition = false; // Autocomplete state List _suggestions = []; bool _searching = false; Timer? _debounce; + // Optional nutrition fields + final _caloriesController = TextEditingController(); + final _proteinController = TextEditingController(); + final _fatController = TextEditingController(); + final _carbsController = TextEditingController(); + final _fiberController = TextEditingController(); + @override void dispose() { _nameController.dispose(); _qtyController.dispose(); _daysController.dispose(); + _caloriesController.dispose(); + _proteinController.dispose(); + _fatController.dispose(); + _carbsController.dispose(); + _fiberController.dispose(); _debounce?.cancel(); super.dispose(); } @@ -73,6 +87,36 @@ class _AddProductScreenState extends ConsumerState { if (catalogProduct.storageDays != null) { _daysController.text = catalogProduct.storageDays.toString(); } + + // Pre-fill nutrition from catalog data. + if (catalogProduct.caloriesPer100g != null) { + _caloriesController.text = + catalogProduct.caloriesPer100g!.toStringAsFixed(1); + } + if (catalogProduct.proteinPer100g != null) { + _proteinController.text = + catalogProduct.proteinPer100g!.toStringAsFixed(1); + } + if (catalogProduct.fatPer100g != null) { + _fatController.text = catalogProduct.fatPer100g!.toStringAsFixed(1); + } + if (catalogProduct.carbsPer100g != null) { + _carbsController.text = + catalogProduct.carbsPer100g!.toStringAsFixed(1); + } + if (catalogProduct.fiberPer100g != null) { + _fiberController.text = + catalogProduct.fiberPer100g!.toStringAsFixed(1); + } + + // Auto-expand nutrition section when any value is available. + final hasNutrition = catalogProduct.caloriesPer100g != null || + catalogProduct.proteinPer100g != null || + catalogProduct.fatPer100g != null || + catalogProduct.carbsPer100g != null || + catalogProduct.fiberPer100g != null; + if (hasNutrition) _showNutrition = true; + _suggestions = []; }); } @@ -88,6 +132,8 @@ class _AddProductScreenState extends ConsumerState { final qty = double.tryParse(_qtyController.text) ?? 1; final days = int.tryParse(_daysController.text) ?? 7; + final messenger = ScaffoldMessenger.of(context); + final addedText = AppLocalizations.of(context)!.productAddedToShelf; setState(() => _saving = true); try { @@ -98,8 +144,18 @@ class _AddProductScreenState extends ConsumerState { category: _category, storageDays: days, primaryProductId: _primaryProductId, + caloriesPer100g: double.tryParse(_caloriesController.text), + proteinPer100g: double.tryParse(_proteinController.text), + fatPer100g: double.tryParse(_fatController.text), + carbsPer100g: double.tryParse(_carbsController.text), + fiberPer100g: double.tryParse(_fiberController.text), ); - if (mounted) Navigator.pop(context); + if (mounted) { + messenger.showSnackBar( + SnackBar(content: Text('$name — $addedText')), + ); + Navigator.pop(context); + } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -113,6 +169,7 @@ class _AddProductScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar(title: const Text('Добавить продукт')), body: GestureDetector( @@ -210,7 +267,80 @@ class _AddProductScreenState extends ConsumerState { ), ), - const SizedBox(height: 24), + const SizedBox(height: 16), + + // Nutrition section (optional, collapsed by default) + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setState(() => _showNutrition = !_showNutrition), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon( + _showNutrition + ? Icons.expand_less + : Icons.expand_more, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + l10n.nutritionOptional, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + + if (_showNutrition) ...[ + const SizedBox(height: 8), + _NutritionField( + controller: _caloriesController, + label: l10n.calories, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _NutritionField( + controller: _proteinController, + label: l10n.protein, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _NutritionField( + controller: _fatController, + label: l10n.fat, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _NutritionField( + controller: _carbsController, + label: l10n.carbs, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _NutritionField( + controller: _fiberController, + label: l10n.fiber, + ), + ), + ], + ), + const SizedBox(height: 8), + ], + + const SizedBox(height: 16), FilledButton( onPressed: _saving ? null : _submit, @@ -249,3 +379,27 @@ class _AddProductScreenState extends ConsumerState { } } } + +// --------------------------------------------------------------------------- +// Compact numeric text field for nutrition values +// --------------------------------------------------------------------------- + +class _NutritionField extends StatelessWidget { + const _NutritionField({required this.controller, required this.label}); + + final TextEditingController controller; + final String label; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + ), + ); + } +} diff --git a/client/lib/features/products/product_search_screen.dart b/client/lib/features/products/product_search_screen.dart new file mode 100644 index 0000000..641f61c --- /dev/null +++ b/client/lib/features/products/product_search_screen.dart @@ -0,0 +1,581 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/locale/unit_provider.dart'; +import '../../l10n/app_localizations.dart'; +import '../../shared/models/product.dart'; +import 'user_product_provider.dart'; + +class ProductSearchScreen extends ConsumerStatefulWidget { + const ProductSearchScreen({super.key}); + + @override + ConsumerState createState() => + _ProductSearchScreenState(); +} + +class _ProductSearchScreenState extends ConsumerState { + final _searchController = TextEditingController(); + Timer? _debounce; + List _results = []; + bool _isLoading = false; + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + _debounce?.cancel(); + super.dispose(); + } + + void _onQueryChanged(String value) { + _debounce?.cancel(); + setState(() => _query = value.trim()); + + if (value.trim().isEmpty) { + setState(() => _results = []); + return; + } + + _debounce = Timer(const Duration(milliseconds: 300), () async { + setState(() => _isLoading = true); + try { + final service = ref.read(userProductServiceProvider); + final searchResults = await service.searchProducts(value.trim()); + if (mounted) setState(() => _results = searchResults); + } finally { + if (mounted) setState(() => _isLoading = false); + } + }); + } + + void _openShelfSheet(CatalogProduct catalogProduct) { + final messenger = ScaffoldMessenger.of(context); + final productName = catalogProduct.displayName; + final addedText = AppLocalizations.of(context)!.productAddedToShelf; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _AddToShelfSheet( + catalogProduct: catalogProduct, + onAdded: () => messenger.showSnackBar( + SnackBar(content: Text('$productName — $addedText')), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.addProduct), + actions: [ + TextButton( + onPressed: () => context.push('/products/add'), + child: Text(l10n.addManually), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _searchController, + onChanged: _onQueryChanged, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: l10n.searchProducts, + prefixIcon: const Icon(Icons.search), + suffixIcon: _isLoading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : _query.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onQueryChanged(''); + }, + ) + : null, + border: const OutlineInputBorder(), + ), + ), + ), + Expanded( + child: _buildBody(l10n), + ), + ], + ), + ); + } + + Widget _buildBody(AppLocalizations l10n) { + if (_query.isEmpty) { + return _EmptyQueryPrompt(onAddManually: () => context.push('/products/add')); + } + if (_isLoading && _results.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (!_isLoading && _results.isEmpty) { + return _NoResultsView( + query: _query, + onAddManually: () => context.push('/products/add'), + ); + } + return ListView.separated( + itemCount: _results.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) => _CatalogProductTile( + catalogProduct: _results[index], + onTap: () => _openShelfSheet(_results[index]), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Empty query state +// --------------------------------------------------------------------------- + +class _EmptyQueryPrompt extends StatelessWidget { + const _EmptyQueryPrompt({required this.onAddManually}); + + final VoidCallback onAddManually; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + l10n.searchProductsHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: onAddManually, + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.addManually), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// No results state +// --------------------------------------------------------------------------- + +class _NoResultsView extends StatelessWidget { + const _NoResultsView({required this.query, required this.onAddManually}); + + final String query; + final VoidCallback onAddManually; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + l10n.noSearchResults(query), + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onAddManually, + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.addManually), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Catalog product tile +// --------------------------------------------------------------------------- + +class _CatalogProductTile extends StatelessWidget { + const _CatalogProductTile({ + required this.catalogProduct, + required this.onTap, + }); + + final CatalogProduct catalogProduct; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.secondaryContainer, + child: Text( + _categoryEmoji(catalogProduct.category), + style: const TextStyle(fontSize: 20), + ), + ), + title: Text(catalogProduct.displayName), + subtitle: catalogProduct.categoryName != null + ? Text( + catalogProduct.categoryName!, + style: theme.textTheme.bodySmall, + ) + : null, + trailing: catalogProduct.caloriesPer100g != null + ? Text( + '${catalogProduct.caloriesPer100g!.round()} kcal', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + onTap: onTap, + ); + } + + String _categoryEmoji(String? category) { + switch (category) { + case 'meat': + return '🥩'; + case 'dairy': + return '🥛'; + case 'vegetable': + return '🥕'; + case 'fruit': + return '🍎'; + case 'grain': + return '🌾'; + case 'seafood': + return '🐟'; + case 'condiment': + return '🧂'; + default: + return '🛒'; + } + } +} + +// --------------------------------------------------------------------------- +// Add to shelf bottom sheet +// --------------------------------------------------------------------------- + +class _AddToShelfSheet extends ConsumerStatefulWidget { + const _AddToShelfSheet({ + required this.catalogProduct, + required this.onAdded, + }); + + final CatalogProduct catalogProduct; + final VoidCallback onAdded; + + @override + ConsumerState<_AddToShelfSheet> createState() => _AddToShelfSheetState(); +} + +class _AddToShelfSheetState extends ConsumerState<_AddToShelfSheet> { + late final TextEditingController _qtyController; + late final TextEditingController _daysController; + late String _unit; + bool _saving = false; + bool _success = false; + + @override + void initState() { + super.initState(); + _qtyController = TextEditingController(text: '1'); + _daysController = TextEditingController( + text: (widget.catalogProduct.storageDays ?? 7).toString(), + ); + _unit = widget.catalogProduct.defaultUnit ?? 'pcs'; + } + + @override + void dispose() { + _qtyController.dispose(); + _daysController.dispose(); + super.dispose(); + } + + Future _confirm() async { + final quantity = double.tryParse(_qtyController.text) ?? 1; + final storageDays = int.tryParse(_daysController.text) ?? 7; + + setState(() => _saving = true); + try { + await ref.read(userProductsProvider.notifier).create( + name: widget.catalogProduct.displayName, + quantity: quantity, + unit: _unit, + category: widget.catalogProduct.category, + storageDays: storageDays, + primaryProductId: widget.catalogProduct.id, + ); + if (mounted) { + setState(() { + _saving = false; + _success = true; + }); + await Future.delayed(const Duration(milliseconds: 700)); + if (mounted) { + widget.onAdded(); + Navigator.pop(context); + } + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.errorGeneric)), + ); + setState(() => _saving = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final insets = MediaQuery.viewInsetsOf(context); + final theme = Theme.of(context); + + return Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!_success) ...[ + Text( + widget.catalogProduct.displayName, + style: theme.textTheme.titleMedium, + ), + if (widget.catalogProduct.categoryName != null) ...[ + const SizedBox(height: 2), + Text( + widget.catalogProduct.categoryName!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 16), + ], + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _success + ? _SuccessView( + key: const ValueKey('success'), + productName: widget.catalogProduct.displayName, + ) + : _ShelfForm( + key: const ValueKey('form'), + l10n: l10n, + theme: theme, + qtyController: _qtyController, + daysController: _daysController, + unit: _unit, + saving: _saving, + onUnitChanged: (value) => setState(() => _unit = value), + onConfirm: _confirm, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Form content extracted for AnimatedSwitcher key stability +// --------------------------------------------------------------------------- + +class _ShelfForm extends ConsumerWidget { + const _ShelfForm({ + super.key, + required this.l10n, + required this.theme, + required this.qtyController, + required this.daysController, + required this.unit, + required this.saving, + required this.onUnitChanged, + required this.onConfirm, + }); + + final AppLocalizations l10n; + final ThemeData theme; + final TextEditingController qtyController; + final TextEditingController daysController; + final String unit; + final bool saving; + final ValueChanged onUnitChanged; + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: qtyController, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: l10n.quantity, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + ref.watch(unitsProvider).when( + data: (units) => DropdownButtonHideUnderline( + child: DropdownButton( + value: units.containsKey(unit) ? unit : units.keys.first, + items: units.entries + .map((entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + onChanged: (value) => onUnitChanged(value!), + ), + ), + loading: () => const SizedBox( + width: 60, + child: LinearProgressIndicator(), + ), + error: (_, __) => const Text('?'), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: daysController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: l10n.storageDays, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: saving ? null : onConfirm, + child: saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.addToShelf), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Success view shown briefly after the product is added +// --------------------------------------------------------------------------- + +class _SuccessView extends StatelessWidget { + const _SuccessView({super.key, required this.productName}); + + final String productName; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + color: Colors.green, + size: 36, + ), + ), + ), + const SizedBox(height: 16), + Text( + productName, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + l10n.productAddedToShelf, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + ], + ); + } +} diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index 67ed900..9ded568 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -9,33 +9,28 @@ import '../scan/recognition_service.dart'; import 'product_job_provider.dart'; import 'user_product_provider.dart'; -void _showAddMenu(BuildContext context) { - showModalBottomSheet( +Future _confirmClearAll( + BuildContext context, WidgetRef ref, AppLocalizations l10n) async { + final confirmed = await showDialog( context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.edit_outlined), - title: const Text('Добавить вручную'), - onTap: () { - Navigator.pop(ctx); - context.push('/products/add'); - }, - ), - ListTile( - leading: const Icon(Icons.document_scanner_outlined), - title: const Text('Сканировать чек или фото'), - onTap: () { - Navigator.pop(ctx); - context.push('/scan'); - }, - ), - ], - ), + builder: (ctx) => AlertDialog( + title: Text(l10n.clearAllConfirmTitle), + content: Text(l10n.clearAllConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.clearAllProducts), + ), + ], ), ); + if (confirmed == true) { + ref.read(userProductsProvider.notifier).deleteAll(); + } } class ProductsScreen extends ConsumerWidget { @@ -44,22 +39,24 @@ class ProductsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(userProductsProvider); + final l10n = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( title: const Text('Мои продукты'), actions: [ + if (state.valueOrNull?.isNotEmpty == true) + IconButton( + icon: const Icon(Icons.delete_sweep_outlined), + tooltip: l10n.clearAllProducts, + onPressed: () => _confirmClearAll(context, ref, l10n), + ), IconButton( icon: const Icon(Icons.refresh), onPressed: () => ref.read(userProductsProvider.notifier).refresh(), ), ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showAddMenu(context), - icon: const Icon(Icons.add), - label: const Text('Добавить'), - ), body: Column( children: [ const _RecentScansSection(), @@ -70,10 +67,11 @@ class ProductsScreen extends ConsumerWidget { onRetry: () => ref.read(userProductsProvider.notifier).refresh(), ), data: (products) => products.isEmpty - ? _EmptyState(onAdd: () => _showAddMenu(context)) + ? const _EmptyState() : _ProductList(products: products), ), ), + const _BottomActionBar(), ], ), ); @@ -252,7 +250,7 @@ class _ProductList extends ConsumerWidget { return RefreshIndicator( onRefresh: () => ref.read(userProductsProvider.notifier).refresh(), child: ListView( - padding: const EdgeInsets.only(bottom: 80), + padding: EdgeInsets.zero, children: [ if (expiring.isNotEmpty) ...[ _SectionHeader( @@ -563,9 +561,7 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> { // --------------------------------------------------------------------------- class _EmptyState extends StatelessWidget { - const _EmptyState({required this.onAdd}); - - final VoidCallback onAdd; + const _EmptyState(); @override Widget build(BuildContext context) { @@ -588,17 +584,59 @@ class _EmptyState extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Добавьте продукты вручную — или в Итерации 3 сфотографируйте чек', + 'Добавьте продукты вручную или сфотографируйте чек', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 24), - FilledButton.icon( - onPressed: onAdd, - icon: const Icon(Icons.add), - label: const Text('Добавить продукт'), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Bottom action toolbar +// --------------------------------------------------------------------------- + +class _BottomActionBar extends StatelessWidget { + const _BottomActionBar(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border( + top: BorderSide( + color: theme.colorScheme.outlineVariant, + width: 0.5, + ), + ), + ), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => context.push('/products/search'), + icon: const Icon(Icons.add, size: 18), + label: Text(l10n.addProduct), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => context.push('/scan'), + icon: const Icon(Icons.document_scanner_outlined, size: 18), + label: Text(l10n.scan), + ), ), ], ), diff --git a/client/lib/features/products/user_product_provider.dart b/client/lib/features/products/user_product_provider.dart index 6b3ab4b..09e8a2d 100644 --- a/client/lib/features/products/user_product_provider.dart +++ b/client/lib/features/products/user_product_provider.dart @@ -46,6 +46,11 @@ class UserProductsNotifier String? category, int storageDays = 7, String? primaryProductId, + double? caloriesPer100g, + double? proteinPer100g, + double? fatPer100g, + double? carbsPer100g, + double? fiberPer100g, }) async { final userProduct = await _service.createProduct( name: name, @@ -54,6 +59,11 @@ class UserProductsNotifier category: category, storageDays: storageDays, primaryProductId: primaryProductId, + caloriesPer100g: caloriesPer100g, + proteinPer100g: proteinPer100g, + fatPer100g: fatPer100g, + carbsPer100g: carbsPer100g, + fiberPer100g: fiberPer100g, ); state.whenData((products) { final updated = [...products, userProduct] @@ -86,6 +96,20 @@ class UserProductsNotifier }); } + /// Optimistically clears all products, restores on error. + Future deleteAll() async { + final previous = state; + state.whenData((_) { + state = const AsyncValue.data([]); + }); + try { + await _service.deleteAllProducts(); + } catch (_) { + state = previous; + rethrow; + } + } + /// Optimistically removes the product, restores on error. Future delete(String id) async { final previous = state; diff --git a/client/lib/features/products/user_product_service.dart b/client/lib/features/products/user_product_service.dart index 48f9a86..6133061 100644 --- a/client/lib/features/products/user_product_service.dart +++ b/client/lib/features/products/user_product_service.dart @@ -21,6 +21,11 @@ class UserProductService { String? category, int storageDays = 7, String? primaryProductId, + double? caloriesPer100g, + double? proteinPer100g, + double? fatPer100g, + double? carbsPer100g, + double? fiberPer100g, }) async { final data = await _client.post('/user-products', data: { 'name': name, @@ -29,6 +34,11 @@ class UserProductService { if (category != null) 'category': category, 'storage_days': storageDays, if (primaryProductId != null) 'primary_product_id': primaryProductId, + if (caloriesPer100g != null) 'calories_per_100g': caloriesPer100g, + if (proteinPer100g != null) 'protein_per_100g': proteinPer100g, + if (fatPer100g != null) 'fat_per_100g': fatPer100g, + if (carbsPer100g != null) 'carbs_per_100g': carbsPer100g, + if (fiberPer100g != null) 'fiber_per_100g': fiberPer100g, }); return UserProduct.fromJson(data); } @@ -54,6 +64,8 @@ class UserProductService { Future deleteProduct(String id) => _client.deleteVoid('/user-products/$id'); + Future deleteAllProducts() => _client.deleteVoid('/user-products'); + Future> searchProducts(String query) async { if (query.isEmpty) return []; final list = await _client.getList( diff --git a/client/lib/l10n/app_ar.arb b/client/lib/l10n/app_ar.arb index 2f69443..669d1a8 100644 --- a/client/lib/l10n/app_ar.arb +++ b/client/lib/l10n/app_ar.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "إيصال", "jobTypeProducts": "منتجات", "scanSubmitting": "جارٍ الإرسال...", - "processingProducts": "جارٍ المعالجة..." + "processingProducts": "جارٍ المعالجة...", + "clearAllProducts": "مسح الكل", + "clearAllConfirmTitle": "مسح جميع المنتجات؟", + "clearAllConfirmMessage": "سيتم حذف جميع المنتجات نهائياً.", + "addManually": "يدويًا", + "scan": "مسح ضوئي", + "addProduct": "إضافة", + "searchProducts": "البحث عن المنتجات", + "searchProductsHint": "اكتب اسم المنتج أو أضف يدويًا", + "noSearchResults": "لا توجد نتائج لـ\"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "الكمية", + "storageDays": "أيام التخزين", + "addToShelf": "إضافة إلى المخزن", + "errorGeneric": "حدث خطأ ما", + "nutritionOptional": "القيم الغذائية لكل 100 جم (اختياري)", + "calories": "سعرات حرارية", + "protein": "بروتين", + "fat": "دهون", + "carbs": "كربوهيدرات", + "fiber": "ألياف", + "productAddedToShelf": "تمت الإضافة إلى المخزن" } diff --git a/client/lib/l10n/app_de.arb b/client/lib/l10n/app_de.arb index cce5b8b..c00833c 100644 --- a/client/lib/l10n/app_de.arb +++ b/client/lib/l10n/app_de.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Kassenbon", "jobTypeProducts": "Produkte", "scanSubmitting": "Wird gesendet...", - "processingProducts": "Verarbeitung..." + "processingProducts": "Verarbeitung...", + "clearAllProducts": "Alles löschen", + "clearAllConfirmTitle": "Alle Produkte löschen?", + "clearAllConfirmMessage": "Alle Produkte werden dauerhaft gelöscht.", + "addManually": "Manuell", + "scan": "Scannen", + "addProduct": "Hinzufügen", + "searchProducts": "Produkte suchen", + "searchProductsHint": "Produktname eingeben oder manuell hinzufügen", + "noSearchResults": "Keine Ergebnisse für \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Menge", + "storageDays": "Lagertage", + "addToShelf": "Zum Vorrat hinzufügen", + "errorGeneric": "Etwas ist schiefgelaufen", + "nutritionOptional": "Nährwerte pro 100g (optional)", + "calories": "Kalorien", + "protein": "Eiweiß", + "fat": "Fett", + "carbs": "Kohlenhydrate", + "fiber": "Ballaststoffe", + "productAddedToShelf": "Zum Vorrat hinzugefügt" } diff --git a/client/lib/l10n/app_en.arb b/client/lib/l10n/app_en.arb index cbb9bff..b794ff3 100644 --- a/client/lib/l10n/app_en.arb +++ b/client/lib/l10n/app_en.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Receipt", "jobTypeProducts": "Products", "scanSubmitting": "Submitting...", - "processingProducts": "Processing..." + "processingProducts": "Processing...", + "clearAllProducts": "Clear all", + "clearAllConfirmTitle": "Clear all products?", + "clearAllConfirmMessage": "All products will be permanently deleted.", + "addManually": "Manually", + "scan": "Scan", + "addProduct": "Add", + "searchProducts": "Search products", + "searchProductsHint": "Type a product name to search or add manually", + "noSearchResults": "No results for \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Quantity", + "storageDays": "Storage days", + "addToShelf": "Add to pantry", + "errorGeneric": "Something went wrong", + "nutritionOptional": "Nutrition per 100g (optional)", + "calories": "Calories", + "protein": "Protein", + "fat": "Fat", + "carbs": "Carbohydrates", + "fiber": "Fiber", + "productAddedToShelf": "Added to pantry" } diff --git a/client/lib/l10n/app_es.arb b/client/lib/l10n/app_es.arb index 930529b..eb08c03 100644 --- a/client/lib/l10n/app_es.arb +++ b/client/lib/l10n/app_es.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Ticket", "jobTypeProducts": "Productos", "scanSubmitting": "Enviando...", - "processingProducts": "Procesando..." + "processingProducts": "Procesando...", + "clearAllProducts": "Borrar todo", + "clearAllConfirmTitle": "¿Borrar todos los productos?", + "clearAllConfirmMessage": "Todos los productos serán eliminados permanentemente.", + "addManually": "Manual", + "scan": "Escanear", + "addProduct": "Agregar", + "searchProducts": "Buscar productos", + "searchProductsHint": "Escribe el nombre del producto o agrega manualmente", + "noSearchResults": "Sin resultados para \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Cantidad", + "storageDays": "Días de almacenamiento", + "addToShelf": "Agregar a despensa", + "errorGeneric": "Algo salió mal", + "nutritionOptional": "Nutrición por 100g (opcional)", + "calories": "Calorías", + "protein": "Proteína", + "fat": "Grasas", + "carbs": "Carbohidratos", + "fiber": "Fibra", + "productAddedToShelf": "Agregado a la despensa" } diff --git a/client/lib/l10n/app_fr.arb b/client/lib/l10n/app_fr.arb index c896586..c47cf59 100644 --- a/client/lib/l10n/app_fr.arb +++ b/client/lib/l10n/app_fr.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Reçu", "jobTypeProducts": "Produits", "scanSubmitting": "Envoi...", - "processingProducts": "Traitement..." + "processingProducts": "Traitement...", + "clearAllProducts": "Tout effacer", + "clearAllConfirmTitle": "Effacer tous les produits ?", + "clearAllConfirmMessage": "Tous les produits seront définitivement supprimés.", + "addManually": "Manuellement", + "scan": "Scanner", + "addProduct": "Ajouter", + "searchProducts": "Rechercher des produits", + "searchProductsHint": "Tapez un nom de produit ou ajoutez manuellement", + "noSearchResults": "Aucun résultat pour \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Quantité", + "storageDays": "Jours de conservation", + "addToShelf": "Ajouter au garde-manger", + "errorGeneric": "Une erreur est survenue", + "nutritionOptional": "Nutrition pour 100g (facultatif)", + "calories": "Calories", + "protein": "Protéines", + "fat": "Graisses", + "carbs": "Glucides", + "fiber": "Fibres", + "productAddedToShelf": "Ajouté au garde-manger" } diff --git a/client/lib/l10n/app_hi.arb b/client/lib/l10n/app_hi.arb index d3e0e5f..80c7534 100644 --- a/client/lib/l10n/app_hi.arb +++ b/client/lib/l10n/app_hi.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "रसीद", "jobTypeProducts": "उत्पाद", "scanSubmitting": "सबमिट हो रहा है...", - "processingProducts": "प्रोसेस हो रहा है..." + "processingProducts": "प्रोसेस हो रहा है...", + "clearAllProducts": "सब साफ करें", + "clearAllConfirmTitle": "सभी उत्पाद साफ करें?", + "clearAllConfirmMessage": "सभी उत्पाद स्थायी रूप से हटा दिए जाएंगे।", + "addManually": "मैन्युअल", + "scan": "स्कैन करें", + "addProduct": "जोड़ें", + "searchProducts": "उत्पाद खोजें", + "searchProductsHint": "उत्पाद का नाम टाइप करें या मैन्युअल रूप से जोड़ें", + "noSearchResults": "\"{query}\" के लिए कोई परिणाम नहीं", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "मात्रा", + "storageDays": "भंडारण दिन", + "addToShelf": "पेंट्री में जोड़ें", + "errorGeneric": "कुछ गलत हो गया", + "nutritionOptional": "पोषण मूल्य प्रति 100 ग्राम (वैकल्पिक)", + "calories": "कैलोरी", + "protein": "प्रोटीन", + "fat": "वसा", + "carbs": "कार्बोहाइड्रेट", + "fiber": "फाइबर", + "productAddedToShelf": "पेंट्री में जोड़ा गया" } diff --git a/client/lib/l10n/app_it.arb b/client/lib/l10n/app_it.arb index 5e9daf7..0819e71 100644 --- a/client/lib/l10n/app_it.arb +++ b/client/lib/l10n/app_it.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Scontrino", "jobTypeProducts": "Prodotti", "scanSubmitting": "Invio...", - "processingProducts": "Elaborazione..." + "processingProducts": "Elaborazione...", + "clearAllProducts": "Cancella tutto", + "clearAllConfirmTitle": "Cancellare tutti i prodotti?", + "clearAllConfirmMessage": "Tutti i prodotti verranno eliminati definitivamente.", + "addManually": "Manualmente", + "scan": "Scansiona", + "addProduct": "Aggiungi", + "searchProducts": "Cerca prodotti", + "searchProductsHint": "Digita il nome del prodotto o aggiungi manualmente", + "noSearchResults": "Nessun risultato per \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Quantità", + "storageDays": "Giorni di conservazione", + "addToShelf": "Aggiungi alla dispensa", + "errorGeneric": "Qualcosa è andato storto", + "nutritionOptional": "Valori nutrizionali per 100g (opzionale)", + "calories": "Calorie", + "protein": "Proteine", + "fat": "Grassi", + "carbs": "Carboidrati", + "fiber": "Fibre", + "productAddedToShelf": "Aggiunto alla dispensa" } diff --git a/client/lib/l10n/app_ja.arb b/client/lib/l10n/app_ja.arb index 3431708..83c7cb4 100644 --- a/client/lib/l10n/app_ja.arb +++ b/client/lib/l10n/app_ja.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "レシート", "jobTypeProducts": "商品", "scanSubmitting": "送信中...", - "processingProducts": "処理中..." + "processingProducts": "処理中...", + "clearAllProducts": "すべてクリア", + "clearAllConfirmTitle": "すべての商品をクリアしますか?", + "clearAllConfirmMessage": "すべての商品が完全に削除されます。", + "addManually": "手動", + "scan": "スキャン", + "addProduct": "追加", + "searchProducts": "製品を検索", + "searchProductsHint": "製品名を入力するか手動で追加してください", + "noSearchResults": "「{query}」の検索結果はありません", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "数量", + "storageDays": "保存日数", + "addToShelf": "パントリーに追加", + "errorGeneric": "エラーが発生しました", + "nutritionOptional": "100gあたりの栄養素(任意)", + "calories": "カロリー", + "protein": "タンパク質", + "fat": "脂質", + "carbs": "炭水化物", + "fiber": "食物繊維", + "productAddedToShelf": "パントリーに追加しました" } diff --git a/client/lib/l10n/app_ko.arb b/client/lib/l10n/app_ko.arb index 894e549..a7cc3fa 100644 --- a/client/lib/l10n/app_ko.arb +++ b/client/lib/l10n/app_ko.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "영수증", "jobTypeProducts": "제품", "scanSubmitting": "제출 중...", - "processingProducts": "처리 중..." + "processingProducts": "처리 중...", + "clearAllProducts": "모두 지우기", + "clearAllConfirmTitle": "모든 제품을 지울까요?", + "clearAllConfirmMessage": "모든 제품이 영구적으로 삭제됩니다.", + "addManually": "수동", + "scan": "스캔", + "addProduct": "추가", + "searchProducts": "제품 검색", + "searchProductsHint": "제품 이름을 입력하거나 수동으로 추가하세요", + "noSearchResults": "「{query}」에 대한 결과가 없습니다", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "수량", + "storageDays": "보관 일수", + "addToShelf": "저장실에 추가", + "errorGeneric": "오류가 발생했습니다", + "nutritionOptional": "100g당 영양성분 (선택사항)", + "calories": "칼로리", + "protein": "단백질", + "fat": "지방", + "carbs": "탄수화물", + "fiber": "식이섬유", + "productAddedToShelf": "저장실에 추가되었습니다" } diff --git a/client/lib/l10n/app_localizations.dart b/client/lib/l10n/app_localizations.dart index 2b0dac0..3f89a47 100644 --- a/client/lib/l10n/app_localizations.dart +++ b/client/lib/l10n/app_localizations.dart @@ -1029,6 +1029,126 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Processing...'** String get processingProducts; + + /// No description provided for @clearAllProducts. + /// + /// In en, this message translates to: + /// **'Clear all'** + String get clearAllProducts; + + /// No description provided for @clearAllConfirmTitle. + /// + /// In en, this message translates to: + /// **'Clear all products?'** + String get clearAllConfirmTitle; + + /// No description provided for @clearAllConfirmMessage. + /// + /// In en, this message translates to: + /// **'All products will be permanently deleted.'** + String get clearAllConfirmMessage; + + /// No description provided for @addManually. + /// + /// In en, this message translates to: + /// **'Manually'** + String get addManually; + + /// No description provided for @scan. + /// + /// In en, this message translates to: + /// **'Scan'** + String get scan; + + /// No description provided for @addProduct. + /// + /// In en, this message translates to: + /// **'Add'** + String get addProduct; + + /// No description provided for @searchProducts. + /// + /// In en, this message translates to: + /// **'Search products'** + String get searchProducts; + + /// No description provided for @searchProductsHint. + /// + /// In en, this message translates to: + /// **'Type a product name to search or add manually'** + String get searchProductsHint; + + /// No description provided for @noSearchResults. + /// + /// In en, this message translates to: + /// **'No results for \"{query}\"'** + String noSearchResults(String query); + + /// No description provided for @quantity. + /// + /// In en, this message translates to: + /// **'Quantity'** + String get quantity; + + /// No description provided for @storageDays. + /// + /// In en, this message translates to: + /// **'Storage days'** + String get storageDays; + + /// No description provided for @addToShelf. + /// + /// In en, this message translates to: + /// **'Add to pantry'** + String get addToShelf; + + /// No description provided for @errorGeneric. + /// + /// In en, this message translates to: + /// **'Something went wrong'** + String get errorGeneric; + + /// No description provided for @nutritionOptional. + /// + /// In en, this message translates to: + /// **'Nutrition per 100g (optional)'** + String get nutritionOptional; + + /// No description provided for @calories. + /// + /// In en, this message translates to: + /// **'Calories'** + String get calories; + + /// No description provided for @protein. + /// + /// In en, this message translates to: + /// **'Protein'** + String get protein; + + /// No description provided for @fat. + /// + /// In en, this message translates to: + /// **'Fat'** + String get fat; + + /// No description provided for @carbs. + /// + /// In en, this message translates to: + /// **'Carbohydrates'** + String get carbs; + + /// No description provided for @fiber. + /// + /// In en, this message translates to: + /// **'Fiber'** + String get fiber; + + /// No description provided for @productAddedToShelf. + /// + /// In en, this message translates to: + /// **'Added to pantry'** + String get productAddedToShelf; } class _AppLocalizationsDelegate diff --git a/client/lib/l10n/app_localizations_ar.dart b/client/lib/l10n/app_localizations_ar.dart index 8de3d48..a964383 100644 --- a/client/lib/l10n/app_localizations_ar.dart +++ b/client/lib/l10n/app_localizations_ar.dart @@ -473,4 +473,66 @@ class AppLocalizationsAr extends AppLocalizations { @override String get processingProducts => 'جارٍ المعالجة...'; + + @override + String get clearAllProducts => 'مسح الكل'; + + @override + String get clearAllConfirmTitle => 'مسح جميع المنتجات؟'; + + @override + String get clearAllConfirmMessage => 'سيتم حذف جميع المنتجات نهائياً.'; + + @override + String get addManually => 'يدويًا'; + + @override + String get scan => 'مسح ضوئي'; + + @override + String get addProduct => 'إضافة'; + + @override + String get searchProducts => 'البحث عن المنتجات'; + + @override + String get searchProductsHint => 'اكتب اسم المنتج أو أضف يدويًا'; + + @override + String noSearchResults(String query) { + return 'لا توجد نتائج لـ\"$query\"'; + } + + @override + String get quantity => 'الكمية'; + + @override + String get storageDays => 'أيام التخزين'; + + @override + String get addToShelf => 'إضافة إلى المخزن'; + + @override + String get errorGeneric => 'حدث خطأ ما'; + + @override + String get nutritionOptional => 'القيم الغذائية لكل 100 جم (اختياري)'; + + @override + String get calories => 'سعرات حرارية'; + + @override + String get protein => 'بروتين'; + + @override + String get fat => 'دهون'; + + @override + String get carbs => 'كربوهيدرات'; + + @override + String get fiber => 'ألياف'; + + @override + String get productAddedToShelf => 'تمت الإضافة إلى المخزن'; } diff --git a/client/lib/l10n/app_localizations_de.dart b/client/lib/l10n/app_localizations_de.dart index dfccba7..f74c4cb 100644 --- a/client/lib/l10n/app_localizations_de.dart +++ b/client/lib/l10n/app_localizations_de.dart @@ -475,4 +475,68 @@ class AppLocalizationsDe extends AppLocalizations { @override String get processingProducts => 'Verarbeitung...'; + + @override + String get clearAllProducts => 'Alles löschen'; + + @override + String get clearAllConfirmTitle => 'Alle Produkte löschen?'; + + @override + String get clearAllConfirmMessage => + 'Alle Produkte werden dauerhaft gelöscht.'; + + @override + String get addManually => 'Manuell'; + + @override + String get scan => 'Scannen'; + + @override + String get addProduct => 'Hinzufügen'; + + @override + String get searchProducts => 'Produkte suchen'; + + @override + String get searchProductsHint => + 'Produktname eingeben oder manuell hinzufügen'; + + @override + String noSearchResults(String query) { + return 'Keine Ergebnisse für \"$query\"'; + } + + @override + String get quantity => 'Menge'; + + @override + String get storageDays => 'Lagertage'; + + @override + String get addToShelf => 'Zum Vorrat hinzufügen'; + + @override + String get errorGeneric => 'Etwas ist schiefgelaufen'; + + @override + String get nutritionOptional => 'Nährwerte pro 100g (optional)'; + + @override + String get calories => 'Kalorien'; + + @override + String get protein => 'Eiweiß'; + + @override + String get fat => 'Fett'; + + @override + String get carbs => 'Kohlenhydrate'; + + @override + String get fiber => 'Ballaststoffe'; + + @override + String get productAddedToShelf => 'Zum Vorrat hinzugefügt'; } diff --git a/client/lib/l10n/app_localizations_en.dart b/client/lib/l10n/app_localizations_en.dart index 0ff2413..1804d72 100644 --- a/client/lib/l10n/app_localizations_en.dart +++ b/client/lib/l10n/app_localizations_en.dart @@ -473,4 +473,68 @@ class AppLocalizationsEn extends AppLocalizations { @override String get processingProducts => 'Processing...'; + + @override + String get clearAllProducts => 'Clear all'; + + @override + String get clearAllConfirmTitle => 'Clear all products?'; + + @override + String get clearAllConfirmMessage => + 'All products will be permanently deleted.'; + + @override + String get addManually => 'Manually'; + + @override + String get scan => 'Scan'; + + @override + String get addProduct => 'Add'; + + @override + String get searchProducts => 'Search products'; + + @override + String get searchProductsHint => + 'Type a product name to search or add manually'; + + @override + String noSearchResults(String query) { + return 'No results for \"$query\"'; + } + + @override + String get quantity => 'Quantity'; + + @override + String get storageDays => 'Storage days'; + + @override + String get addToShelf => 'Add to pantry'; + + @override + String get errorGeneric => 'Something went wrong'; + + @override + String get nutritionOptional => 'Nutrition per 100g (optional)'; + + @override + String get calories => 'Calories'; + + @override + String get protein => 'Protein'; + + @override + String get fat => 'Fat'; + + @override + String get carbs => 'Carbohydrates'; + + @override + String get fiber => 'Fiber'; + + @override + String get productAddedToShelf => 'Added to pantry'; } diff --git a/client/lib/l10n/app_localizations_es.dart b/client/lib/l10n/app_localizations_es.dart index 1754df2..918cd46 100644 --- a/client/lib/l10n/app_localizations_es.dart +++ b/client/lib/l10n/app_localizations_es.dart @@ -475,4 +475,68 @@ class AppLocalizationsEs extends AppLocalizations { @override String get processingProducts => 'Procesando...'; + + @override + String get clearAllProducts => 'Borrar todo'; + + @override + String get clearAllConfirmTitle => '¿Borrar todos los productos?'; + + @override + String get clearAllConfirmMessage => + 'Todos los productos serán eliminados permanentemente.'; + + @override + String get addManually => 'Manual'; + + @override + String get scan => 'Escanear'; + + @override + String get addProduct => 'Agregar'; + + @override + String get searchProducts => 'Buscar productos'; + + @override + String get searchProductsHint => + 'Escribe el nombre del producto o agrega manualmente'; + + @override + String noSearchResults(String query) { + return 'Sin resultados para \"$query\"'; + } + + @override + String get quantity => 'Cantidad'; + + @override + String get storageDays => 'Días de almacenamiento'; + + @override + String get addToShelf => 'Agregar a despensa'; + + @override + String get errorGeneric => 'Algo salió mal'; + + @override + String get nutritionOptional => 'Nutrición por 100g (opcional)'; + + @override + String get calories => 'Calorías'; + + @override + String get protein => 'Proteína'; + + @override + String get fat => 'Grasas'; + + @override + String get carbs => 'Carbohidratos'; + + @override + String get fiber => 'Fibra'; + + @override + String get productAddedToShelf => 'Agregado a la despensa'; } diff --git a/client/lib/l10n/app_localizations_fr.dart b/client/lib/l10n/app_localizations_fr.dart index 3e859cd..81a7923 100644 --- a/client/lib/l10n/app_localizations_fr.dart +++ b/client/lib/l10n/app_localizations_fr.dart @@ -476,4 +476,68 @@ class AppLocalizationsFr extends AppLocalizations { @override String get processingProducts => 'Traitement...'; + + @override + String get clearAllProducts => 'Tout effacer'; + + @override + String get clearAllConfirmTitle => 'Effacer tous les produits ?'; + + @override + String get clearAllConfirmMessage => + 'Tous les produits seront définitivement supprimés.'; + + @override + String get addManually => 'Manuellement'; + + @override + String get scan => 'Scanner'; + + @override + String get addProduct => 'Ajouter'; + + @override + String get searchProducts => 'Rechercher des produits'; + + @override + String get searchProductsHint => + 'Tapez un nom de produit ou ajoutez manuellement'; + + @override + String noSearchResults(String query) { + return 'Aucun résultat pour \"$query\"'; + } + + @override + String get quantity => 'Quantité'; + + @override + String get storageDays => 'Jours de conservation'; + + @override + String get addToShelf => 'Ajouter au garde-manger'; + + @override + String get errorGeneric => 'Une erreur est survenue'; + + @override + String get nutritionOptional => 'Nutrition pour 100g (facultatif)'; + + @override + String get calories => 'Calories'; + + @override + String get protein => 'Protéines'; + + @override + String get fat => 'Graisses'; + + @override + String get carbs => 'Glucides'; + + @override + String get fiber => 'Fibres'; + + @override + String get productAddedToShelf => 'Ajouté au garde-manger'; } diff --git a/client/lib/l10n/app_localizations_hi.dart b/client/lib/l10n/app_localizations_hi.dart index 6bb5b50..4e69f02 100644 --- a/client/lib/l10n/app_localizations_hi.dart +++ b/client/lib/l10n/app_localizations_hi.dart @@ -474,4 +474,68 @@ class AppLocalizationsHi extends AppLocalizations { @override String get processingProducts => 'प्रोसेस हो रहा है...'; + + @override + String get clearAllProducts => 'सब साफ करें'; + + @override + String get clearAllConfirmTitle => 'सभी उत्पाद साफ करें?'; + + @override + String get clearAllConfirmMessage => + 'सभी उत्पाद स्थायी रूप से हटा दिए जाएंगे।'; + + @override + String get addManually => 'मैन्युअल'; + + @override + String get scan => 'स्कैन करें'; + + @override + String get addProduct => 'जोड़ें'; + + @override + String get searchProducts => 'उत्पाद खोजें'; + + @override + String get searchProductsHint => + 'उत्पाद का नाम टाइप करें या मैन्युअल रूप से जोड़ें'; + + @override + String noSearchResults(String query) { + return '\"$query\" के लिए कोई परिणाम नहीं'; + } + + @override + String get quantity => 'मात्रा'; + + @override + String get storageDays => 'भंडारण दिन'; + + @override + String get addToShelf => 'पेंट्री में जोड़ें'; + + @override + String get errorGeneric => 'कुछ गलत हो गया'; + + @override + String get nutritionOptional => 'पोषण मूल्य प्रति 100 ग्राम (वैकल्पिक)'; + + @override + String get calories => 'कैलोरी'; + + @override + String get protein => 'प्रोटीन'; + + @override + String get fat => 'वसा'; + + @override + String get carbs => 'कार्बोहाइड्रेट'; + + @override + String get fiber => 'फाइबर'; + + @override + String get productAddedToShelf => 'पेंट्री में जोड़ा गया'; } diff --git a/client/lib/l10n/app_localizations_it.dart b/client/lib/l10n/app_localizations_it.dart index bc9b092..cc3b6cf 100644 --- a/client/lib/l10n/app_localizations_it.dart +++ b/client/lib/l10n/app_localizations_it.dart @@ -475,4 +475,68 @@ class AppLocalizationsIt extends AppLocalizations { @override String get processingProducts => 'Elaborazione...'; + + @override + String get clearAllProducts => 'Cancella tutto'; + + @override + String get clearAllConfirmTitle => 'Cancellare tutti i prodotti?'; + + @override + String get clearAllConfirmMessage => + 'Tutti i prodotti verranno eliminati definitivamente.'; + + @override + String get addManually => 'Manualmente'; + + @override + String get scan => 'Scansiona'; + + @override + String get addProduct => 'Aggiungi'; + + @override + String get searchProducts => 'Cerca prodotti'; + + @override + String get searchProductsHint => + 'Digita il nome del prodotto o aggiungi manualmente'; + + @override + String noSearchResults(String query) { + return 'Nessun risultato per \"$query\"'; + } + + @override + String get quantity => 'Quantità'; + + @override + String get storageDays => 'Giorni di conservazione'; + + @override + String get addToShelf => 'Aggiungi alla dispensa'; + + @override + String get errorGeneric => 'Qualcosa è andato storto'; + + @override + String get nutritionOptional => 'Valori nutrizionali per 100g (opzionale)'; + + @override + String get calories => 'Calorie'; + + @override + String get protein => 'Proteine'; + + @override + String get fat => 'Grassi'; + + @override + String get carbs => 'Carboidrati'; + + @override + String get fiber => 'Fibre'; + + @override + String get productAddedToShelf => 'Aggiunto alla dispensa'; } diff --git a/client/lib/l10n/app_localizations_ja.dart b/client/lib/l10n/app_localizations_ja.dart index d72ff9c..488eb2f 100644 --- a/client/lib/l10n/app_localizations_ja.dart +++ b/client/lib/l10n/app_localizations_ja.dart @@ -470,4 +470,66 @@ class AppLocalizationsJa extends AppLocalizations { @override String get processingProducts => '処理中...'; + + @override + String get clearAllProducts => 'すべてクリア'; + + @override + String get clearAllConfirmTitle => 'すべての商品をクリアしますか?'; + + @override + String get clearAllConfirmMessage => 'すべての商品が完全に削除されます。'; + + @override + String get addManually => '手動'; + + @override + String get scan => 'スキャン'; + + @override + String get addProduct => '追加'; + + @override + String get searchProducts => '製品を検索'; + + @override + String get searchProductsHint => '製品名を入力するか手動で追加してください'; + + @override + String noSearchResults(String query) { + return '「$query」の検索結果はありません'; + } + + @override + String get quantity => '数量'; + + @override + String get storageDays => '保存日数'; + + @override + String get addToShelf => 'パントリーに追加'; + + @override + String get errorGeneric => 'エラーが発生しました'; + + @override + String get nutritionOptional => '100gあたりの栄養素(任意)'; + + @override + String get calories => 'カロリー'; + + @override + String get protein => 'タンパク質'; + + @override + String get fat => '脂質'; + + @override + String get carbs => '炭水化物'; + + @override + String get fiber => '食物繊維'; + + @override + String get productAddedToShelf => 'パントリーに追加しました'; } diff --git a/client/lib/l10n/app_localizations_ko.dart b/client/lib/l10n/app_localizations_ko.dart index 6a11627..4546fc9 100644 --- a/client/lib/l10n/app_localizations_ko.dart +++ b/client/lib/l10n/app_localizations_ko.dart @@ -470,4 +470,66 @@ class AppLocalizationsKo extends AppLocalizations { @override String get processingProducts => '처리 중...'; + + @override + String get clearAllProducts => '모두 지우기'; + + @override + String get clearAllConfirmTitle => '모든 제품을 지울까요?'; + + @override + String get clearAllConfirmMessage => '모든 제품이 영구적으로 삭제됩니다.'; + + @override + String get addManually => '수동'; + + @override + String get scan => '스캔'; + + @override + String get addProduct => '추가'; + + @override + String get searchProducts => '제품 검색'; + + @override + String get searchProductsHint => '제품 이름을 입력하거나 수동으로 추가하세요'; + + @override + String noSearchResults(String query) { + return '「$query」에 대한 결과가 없습니다'; + } + + @override + String get quantity => '수량'; + + @override + String get storageDays => '보관 일수'; + + @override + String get addToShelf => '저장실에 추가'; + + @override + String get errorGeneric => '오류가 발생했습니다'; + + @override + String get nutritionOptional => '100g당 영양성분 (선택사항)'; + + @override + String get calories => '칼로리'; + + @override + String get protein => '단백질'; + + @override + String get fat => '지방'; + + @override + String get carbs => '탄수화물'; + + @override + String get fiber => '식이섬유'; + + @override + String get productAddedToShelf => '저장실에 추가되었습니다'; } diff --git a/client/lib/l10n/app_localizations_pt.dart b/client/lib/l10n/app_localizations_pt.dart index 15d606d..5d154f5 100644 --- a/client/lib/l10n/app_localizations_pt.dart +++ b/client/lib/l10n/app_localizations_pt.dart @@ -475,4 +475,68 @@ class AppLocalizationsPt extends AppLocalizations { @override String get processingProducts => 'Processando...'; + + @override + String get clearAllProducts => 'Limpar tudo'; + + @override + String get clearAllConfirmTitle => 'Limpar todos os produtos?'; + + @override + String get clearAllConfirmMessage => + 'Todos os produtos serão excluídos permanentemente.'; + + @override + String get addManually => 'Manualmente'; + + @override + String get scan => 'Escanear'; + + @override + String get addProduct => 'Adicionar'; + + @override + String get searchProducts => 'Pesquisar produtos'; + + @override + String get searchProductsHint => + 'Digite o nome do produto ou adicione manualmente'; + + @override + String noSearchResults(String query) { + return 'Sem resultados para \"$query\"'; + } + + @override + String get quantity => 'Quantidade'; + + @override + String get storageDays => 'Dias de armazenamento'; + + @override + String get addToShelf => 'Adicionar à despensa'; + + @override + String get errorGeneric => 'Algo deu errado'; + + @override + String get nutritionOptional => 'Nutrição por 100g (opcional)'; + + @override + String get calories => 'Calorias'; + + @override + String get protein => 'Proteína'; + + @override + String get fat => 'Gorduras'; + + @override + String get carbs => 'Carboidratos'; + + @override + String get fiber => 'Fibra'; + + @override + String get productAddedToShelf => 'Adicionado à despensa'; } diff --git a/client/lib/l10n/app_localizations_ru.dart b/client/lib/l10n/app_localizations_ru.dart index f227450..875c8a7 100644 --- a/client/lib/l10n/app_localizations_ru.dart +++ b/client/lib/l10n/app_localizations_ru.dart @@ -473,4 +473,69 @@ class AppLocalizationsRu extends AppLocalizations { @override String get processingProducts => 'Обработка...'; + + @override + String get clearAllProducts => 'Очистить список'; + + @override + String get clearAllConfirmTitle => 'Очистить список продуктов?'; + + @override + String get clearAllConfirmMessage => + 'Все продукты будут удалены без возможности восстановления.'; + + @override + String get addManually => 'Вручную'; + + @override + String get scan => 'Сканировать'; + + @override + String get addProduct => 'Добавить'; + + @override + String get searchProducts => 'Поиск продуктов'; + + @override + String get searchProductsHint => + 'Введите название продукта или добавьте вручную'; + + @override + String noSearchResults(String query) { + return 'Ничего не найдено по запросу \"$query\"'; + } + + @override + String get quantity => 'Количество'; + + @override + String get storageDays => 'Дней хранения'; + + @override + String get addToShelf => 'В холодильник'; + + @override + String get errorGeneric => 'Что-то пошло не так'; + + @override + String get nutritionOptional => + 'Питательная ценность на 100г (необязательно)'; + + @override + String get calories => 'Калории'; + + @override + String get protein => 'Белки'; + + @override + String get fat => 'Жиры'; + + @override + String get carbs => 'Углеводы'; + + @override + String get fiber => 'Клетчатка'; + + @override + String get productAddedToShelf => 'Добавлено в холодильник'; } diff --git a/client/lib/l10n/app_localizations_zh.dart b/client/lib/l10n/app_localizations_zh.dart index 0e52d52..d3b9e14 100644 --- a/client/lib/l10n/app_localizations_zh.dart +++ b/client/lib/l10n/app_localizations_zh.dart @@ -469,4 +469,66 @@ class AppLocalizationsZh extends AppLocalizations { @override String get processingProducts => '处理中...'; + + @override + String get clearAllProducts => '清空列表'; + + @override + String get clearAllConfirmTitle => '清空所有产品?'; + + @override + String get clearAllConfirmMessage => '所有产品将被永久删除。'; + + @override + String get addManually => '手动'; + + @override + String get scan => '扫描'; + + @override + String get addProduct => '添加'; + + @override + String get searchProducts => '搜索产品'; + + @override + String get searchProductsHint => '输入产品名称搜索或手动添加'; + + @override + String noSearchResults(String query) { + return '未找到$query的结果'; + } + + @override + String get quantity => '数量'; + + @override + String get storageDays => '保存天数'; + + @override + String get addToShelf => '添加到储藏室'; + + @override + String get errorGeneric => '出错了'; + + @override + String get nutritionOptional => '每100克营养成分(可选)'; + + @override + String get calories => '卡路里'; + + @override + String get protein => '蛋白质'; + + @override + String get fat => '脂肪'; + + @override + String get carbs => '碳水化合物'; + + @override + String get fiber => '膳食纤维'; + + @override + String get productAddedToShelf => '已添加到储藏室'; } diff --git a/client/lib/l10n/app_pt.arb b/client/lib/l10n/app_pt.arb index bff6805..fccc907 100644 --- a/client/lib/l10n/app_pt.arb +++ b/client/lib/l10n/app_pt.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Recibo", "jobTypeProducts": "Produtos", "scanSubmitting": "Enviando...", - "processingProducts": "Processando..." + "processingProducts": "Processando...", + "clearAllProducts": "Limpar tudo", + "clearAllConfirmTitle": "Limpar todos os produtos?", + "clearAllConfirmMessage": "Todos os produtos serão excluídos permanentemente.", + "addManually": "Manualmente", + "scan": "Escanear", + "addProduct": "Adicionar", + "searchProducts": "Pesquisar produtos", + "searchProductsHint": "Digite o nome do produto ou adicione manualmente", + "noSearchResults": "Sem resultados para \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Quantidade", + "storageDays": "Dias de armazenamento", + "addToShelf": "Adicionar à despensa", + "errorGeneric": "Algo deu errado", + "nutritionOptional": "Nutrição por 100g (opcional)", + "calories": "Calorias", + "protein": "Proteína", + "fat": "Gorduras", + "carbs": "Carboidratos", + "fiber": "Fibra", + "productAddedToShelf": "Adicionado à despensa" } diff --git a/client/lib/l10n/app_ru.arb b/client/lib/l10n/app_ru.arb index 6d3f50c..f0f32fe 100644 --- a/client/lib/l10n/app_ru.arb +++ b/client/lib/l10n/app_ru.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "Чек", "jobTypeProducts": "Продукты", "scanSubmitting": "Отправка...", - "processingProducts": "Обработка..." + "processingProducts": "Обработка...", + "clearAllProducts": "Очистить список", + "clearAllConfirmTitle": "Очистить список продуктов?", + "clearAllConfirmMessage": "Все продукты будут удалены без возможности восстановления.", + "addManually": "Вручную", + "scan": "Сканировать", + "addProduct": "Добавить", + "searchProducts": "Поиск продуктов", + "searchProductsHint": "Введите название продукта или добавьте вручную", + "noSearchResults": "Ничего не найдено по запросу \"{query}\"", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "Количество", + "storageDays": "Дней хранения", + "addToShelf": "В холодильник", + "errorGeneric": "Что-то пошло не так", + "nutritionOptional": "Питательная ценность на 100г (необязательно)", + "calories": "Калории", + "protein": "Белки", + "fat": "Жиры", + "carbs": "Углеводы", + "fiber": "Клетчатка", + "productAddedToShelf": "Добавлено в холодильник" } diff --git a/client/lib/l10n/app_zh.arb b/client/lib/l10n/app_zh.arb index 32d9fe4..e7dda6e 100644 --- a/client/lib/l10n/app_zh.arb +++ b/client/lib/l10n/app_zh.arb @@ -172,5 +172,32 @@ "jobTypeReceipt": "收据", "jobTypeProducts": "产品", "scanSubmitting": "提交中...", - "processingProducts": "处理中..." + "processingProducts": "处理中...", + "clearAllProducts": "清空列表", + "clearAllConfirmTitle": "清空所有产品?", + "clearAllConfirmMessage": "所有产品将被永久删除。", + "addManually": "手动", + "scan": "扫描", + "addProduct": "添加", + "searchProducts": "搜索产品", + "searchProductsHint": "输入产品名称搜索或手动添加", + "noSearchResults": "未找到{query}的结果", + "@noSearchResults": { + "placeholders": { + "query": { + "type": "String" + } + } + }, + "quantity": "数量", + "storageDays": "保存天数", + "addToShelf": "添加到储藏室", + "errorGeneric": "出错了", + "nutritionOptional": "每100克营养成分(可选)", + "calories": "卡路里", + "protein": "蛋白质", + "fat": "脂肪", + "carbs": "碳水化合物", + "fiber": "膳食纤维", + "productAddedToShelf": "已添加到储藏室" } diff --git a/client/lib/shared/models/product.dart b/client/lib/shared/models/product.dart index 8efc98e..b6dd5ed 100644 --- a/client/lib/shared/models/product.dart +++ b/client/lib/shared/models/product.dart @@ -24,6 +24,8 @@ class CatalogProduct { final double? fatPer100g; @JsonKey(name: 'carbs_per_100g') final double? carbsPer100g; + @JsonKey(name: 'fiber_per_100g') + final double? fiberPer100g; const CatalogProduct({ required this.id, @@ -37,6 +39,7 @@ class CatalogProduct { this.proteinPer100g, this.fatPer100g, this.carbsPer100g, + this.fiberPer100g, }); /// Display name is the server-resolved canonical name (language-aware from backend). diff --git a/client/lib/shared/models/product.g.dart b/client/lib/shared/models/product.g.dart index 4224401..e7bcbe2 100644 --- a/client/lib/shared/models/product.g.dart +++ b/client/lib/shared/models/product.g.dart @@ -19,6 +19,7 @@ CatalogProduct _$CatalogProductFromJson(Map json) => proteinPer100g: (json['protein_per_100g'] as num?)?.toDouble(), fatPer100g: (json['fat_per_100g'] as num?)?.toDouble(), carbsPer100g: (json['carbs_per_100g'] as num?)?.toDouble(), + fiberPer100g: (json['fiber_per_100g'] as num?)?.toDouble(), ); Map _$CatalogProductToJson(CatalogProduct instance) => @@ -34,4 +35,5 @@ Map _$CatalogProductToJson(CatalogProduct instance) => 'protein_per_100g': instance.proteinPer100g, 'fat_per_100g': instance.fatPer100g, 'carbs_per_100g': instance.carbsPer100g, + 'fiber_per_100g': instance.fiberPer100g, }; diff --git a/client/lib/shared/models/saved_recipe.g.dart b/client/lib/shared/models/saved_recipe.g.dart deleted file mode 100644 index b6a0b6c..0000000 --- a/client/lib/shared/models/saved_recipe.g.dart +++ /dev/null @@ -1,3 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// This file is intentionally empty. -// saved_recipe.dart now uses a manually written fromJson/toJson. diff --git a/client/pubspec.lock b/client/pubspec.lock index a2acdd5..223b5f2 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -689,26 +689,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1078,10 +1078,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: