diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5462ef7..a009ca9 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -20,6 +20,7 @@ import ( "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/units" "github.com/food-ai/backend/internal/pexels" "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recognition" @@ -78,6 +79,11 @@ func run() error { } slog.Info("languages loaded", "count", len(locale.Languages)) + if err := units.LoadFromDB(ctx, pool); err != nil { + return fmt.Errorf("load units: %w", err) + } + slog.Info("units loaded", "count", len(units.Records)) + // Firebase auth firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile) if err != nil { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 081d75a..8f91914 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -11,6 +11,7 @@ import ( "github.com/food-ai/backend/internal/language" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/units" "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recognition" "github.com/food-ai/backend/internal/recommendation" @@ -47,6 +48,7 @@ func NewRouter( // Public r.Get("/health", healthCheck(pool)) r.Get("/languages", language.List) + r.Get("/units", units.List) r.Route("/auth", func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/refresh", authHandler.Refresh) diff --git a/backend/internal/units/handler.go b/backend/internal/units/handler.go new file mode 100644 index 0000000..ed26917 --- /dev/null +++ b/backend/internal/units/handler.go @@ -0,0 +1,28 @@ +package units + +import ( + "encoding/json" + "net/http" + + "github.com/food-ai/backend/internal/locale" +) + +type unitItem struct { + Code string `json:"code"` + Name string `json:"name"` +} + +// List handles GET /units — returns units with names in the requested language. +func List(w http.ResponseWriter, r *http.Request) { + lang := locale.FromContext(r.Context()) + items := make([]unitItem, 0, len(Records)) + for _, u := range Records { + name, ok := u.Translations[lang] + if !ok { + name = u.Code // fallback to English code + } + items = append(items, unitItem{Code: u.Code, Name: name}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"units": items}) +} diff --git a/backend/internal/units/registry.go b/backend/internal/units/registry.go new file mode 100644 index 0000000..eaa91bd --- /dev/null +++ b/backend/internal/units/registry.go @@ -0,0 +1,73 @@ +package units + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Record is a unit loaded from DB with all its translations. +type Record struct { + Code string + SortOrder int + Translations map[string]string // lang → localized name +} + +// Records is the ordered list of active units, populated by LoadFromDB at startup. +var Records []Record + +// LoadFromDB queries units + unit_translations and populates Records. +func LoadFromDB(ctx context.Context, pool *pgxpool.Pool) error { + rows, err := pool.Query(ctx, ` + SELECT u.code, u.sort_order, ut.lang, ut.name + FROM units u + LEFT JOIN unit_translations ut ON ut.unit_code = u.code + ORDER BY u.sort_order, ut.lang`) + if err != nil { + return fmt.Errorf("load units from db: %w", err) + } + defer rows.Close() + + byCode := map[string]*Record{} + var order []string + for rows.Next() { + var code string + var sortOrder int + var lang, name *string + if err := rows.Scan(&code, &sortOrder, &lang, &name); err != nil { + return err + } + if _, ok := byCode[code]; !ok { + byCode[code] = &Record{Code: code, SortOrder: sortOrder, Translations: map[string]string{}} + order = append(order, code) + } + if lang != nil && name != nil { + byCode[code].Translations[*lang] = *name + } + } + if err := rows.Err(); err != nil { + return err + } + + result := make([]Record, 0, len(order)) + for _, code := range order { + result = append(result, *byCode[code]) + } + Records = result + return nil +} + +// NameFor returns the localized name for a unit code. +// Falls back to the code itself when no translation exists. +func NameFor(code, lang string) string { + for _, u := range Records { + if u.Code == code { + if name, ok := u.Translations[lang]; ok { + return name + } + return u.Code + } + } + return code +} diff --git a/backend/migrations/014_create_units.sql b/backend/migrations/014_create_units.sql new file mode 100644 index 0000000..54f8446 --- /dev/null +++ b/backend/migrations/014_create_units.sql @@ -0,0 +1,58 @@ +-- +goose Up + +CREATE TABLE units ( + code VARCHAR(20) PRIMARY KEY, + sort_order SMALLINT NOT NULL DEFAULT 0 +); +INSERT INTO units (code, sort_order) VALUES + ('g', 1), + ('kg', 2), + ('ml', 3), + ('l', 4), + ('pcs', 5), + ('pack', 6); + +CREATE TABLE unit_translations ( + unit_code VARCHAR(20) NOT NULL REFERENCES units(code) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (unit_code, lang) +); +INSERT INTO unit_translations (unit_code, lang, name) VALUES + ('g', 'ru', 'г'), + ('kg', 'ru', 'кг'), + ('ml', 'ru', 'мл'), + ('l', 'ru', 'л'), + ('pcs', 'ru', 'шт'), + ('pack', 'ru', 'уп'); + +-- Normalize products.unit from Russian display strings to English codes +UPDATE products SET unit = 'g' WHERE unit = 'г'; +UPDATE products SET unit = 'kg' WHERE unit = 'кг'; +UPDATE products SET unit = 'ml' WHERE unit = 'мл'; +UPDATE products SET unit = 'l' WHERE unit = 'л'; +UPDATE products SET unit = 'pcs' WHERE unit = 'шт'; +UPDATE products SET unit = 'pack' WHERE unit = 'уп'; +-- Normalize any remaining unknown values +UPDATE products SET unit = 'pcs' WHERE unit NOT IN (SELECT code FROM units); + +-- Nullify unknown default_unit values in ingredient_mappings before adding FK +UPDATE ingredient_mappings + SET default_unit = NULL + WHERE default_unit IS NOT NULL + AND default_unit NOT IN (SELECT code FROM units); + +-- Foreign key constraints +ALTER TABLE products + ADD CONSTRAINT fk_product_unit + FOREIGN KEY (unit) REFERENCES units(code); + +ALTER TABLE ingredient_mappings + ADD CONSTRAINT fk_default_unit + FOREIGN KEY (default_unit) REFERENCES units(code); + +-- +goose Down +ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_default_unit; +ALTER TABLE products DROP CONSTRAINT IF EXISTS fk_product_unit; +DROP TABLE IF EXISTS unit_translations; +DROP TABLE IF EXISTS units; diff --git a/client/lib/core/api/language_repository.dart b/client/lib/core/api/language_repository.dart index 288cfd7..3ee51a7 100644 --- a/client/lib/core/api/language_repository.dart +++ b/client/lib/core/api/language_repository.dart @@ -8,8 +8,8 @@ class LanguageRepository { LanguageRepository(this._api); Future> fetchLanguages() async { - final response = await _api.dio.get('/languages'); - final List items = response.data['languages'] as List; + final data = await _api.get('/languages'); + final List items = data['languages'] as List; return { for (final item in items) item['code'] as String: item['native_name'] as String, diff --git a/client/lib/core/api/unit_repository.dart b/client/lib/core/api/unit_repository.dart new file mode 100644 index 0000000..d633e03 --- /dev/null +++ b/client/lib/core/api/unit_repository.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import 'api_client.dart'; + +class UnitRepository { + final ApiClient _api; + UnitRepository(this._api); + + Future> fetchUnits() async { + final data = await _api.get('/units'); + final List items = data['units'] as List; + return { + for (final item in items) + item['code'] as String: item['name'] as String, + }; + } +} + +final unitRepositoryProvider = Provider( + (ref) => UnitRepository(ref.watch(apiClientProvider)), +); diff --git a/client/lib/core/locale/unit_provider.dart b/client/lib/core/locale/unit_provider.dart new file mode 100644 index 0000000..5b27036 --- /dev/null +++ b/client/lib/core/locale/unit_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/unit_repository.dart'; +import 'language_provider.dart'; + +/// Fetches and caches units with localized names. +/// Returns map of code → localized name (e.g. {'g': 'г', 'kg': 'кг'}). +/// Re-fetches automatically when languageProvider changes. +final unitsProvider = FutureProvider>((ref) { + ref.watch(languageProvider); // invalidate when language changes + return ref.read(unitRepositoryProvider).fetchUnits(); +}); diff --git a/client/lib/features/menu/shopping_list_screen.dart b/client/lib/features/menu/shopping_list_screen.dart index 5bbbe98..e5ab874 100644 --- a/client/lib/features/menu/shopping_list_screen.dart +++ b/client/lib/features/menu/shopping_list_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/locale/unit_provider.dart'; import '../../shared/models/shopping_item.dart'; import 'menu_provider.dart'; @@ -191,14 +192,14 @@ class _ShoppingTile extends ConsumerWidget { ), subtitle: item.inStock > 0 ? Text( - '${item.inStock.toStringAsFixed(0)} ${item.unit} есть дома', + '${item.inStock.toStringAsFixed(0)} ${ref.watch(unitsProvider).valueOrNull?[item.unit] ?? item.unit} есть дома', style: theme.textTheme.bodySmall?.copyWith( color: Colors.green, ), ) : null, trailing: Text( - '$amountStr ${item.unit}', + '$amountStr ${ref.watch(unitsProvider).valueOrNull?[item.unit] ?? item.unit}', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), diff --git a/client/lib/features/products/add_product_screen.dart b/client/lib/features/products/add_product_screen.dart index 7f1b10a..a33ec6e 100644 --- a/client/lib/features/products/add_product_screen.dart +++ b/client/lib/features/products/add_product_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; 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'; @@ -18,7 +19,7 @@ class _AddProductScreenState extends ConsumerState { final _qtyController = TextEditingController(text: '1'); final _daysController = TextEditingController(text: '7'); - String _unit = 'шт'; + String _unit = 'pcs'; String? _category; String? _mappingId; bool _saving = false; @@ -28,8 +29,6 @@ class _AddProductScreenState extends ConsumerState { bool _searching = false; Timer? _debounce; - static const _units = ['г', 'кг', 'мл', 'л', 'шт']; - @override void dispose() { _nameController.dispose(); @@ -69,8 +68,7 @@ class _AddProductScreenState extends ConsumerState { _mappingId = mapping.id; _category = mapping.category; if (mapping.defaultUnit != null) { - // Map backend unit codes to display units - _unit = _mapUnit(mapping.defaultUnit!); + _unit = mapping.defaultUnit!; } if (mapping.storageDays != null) { _daysController.text = mapping.storageDays.toString(); @@ -79,21 +77,6 @@ class _AddProductScreenState extends ConsumerState { }); } - String _mapUnit(String backendUnit) { - switch (backendUnit.toLowerCase()) { - case 'g': - return 'г'; - case 'kg': - return 'кг'; - case 'ml': - return 'мл'; - case 'l': - return 'л'; - default: - return 'шт'; - } - } - Future _submit() async { final name = _nameController.text.trim(); if (name.isEmpty) { @@ -170,7 +153,8 @@ class _AddProductScreenState extends ConsumerState { ? Text(_categoryLabel(m.category!)) : null, trailing: m.defaultUnit != null - ? Text(m.defaultUnit!, + ? Text( + ref.watch(unitsProvider).valueOrNull?[m.defaultUnit!] ?? m.defaultUnit!, style: Theme.of(context).textTheme.bodySmall) : null, @@ -197,15 +181,18 @@ class _AddProductScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - DropdownButtonHideUnderline( - child: DropdownButton( - value: _units.contains(_unit) ? _unit : _units.last, - items: _units - .map((u) => - DropdownMenuItem(value: u, child: Text(u))) - .toList(), - onChanged: (v) => setState(() => _unit = v!), + ref.watch(unitsProvider).when( + data: (units) => DropdownButtonHideUnderline( + child: DropdownButton( + value: units.containsKey(_unit) ? _unit : units.keys.first, + items: units.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => setState(() => _unit = v!), + ), ), + loading: () => const SizedBox(width: 60, child: LinearProgressIndicator()), + error: (_, __) => const Text('?'), ), ], ), diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart index f70be37..40729ab 100644 --- a/client/lib/features/products/products_screen.dart +++ b/client/lib/features/products/products_screen.dart @@ -2,6 +2,7 @@ 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 '../../shared/models/product.dart'; import 'product_provider.dart'; @@ -205,7 +206,7 @@ class _ProductTile extends ConsumerWidget { ), title: Text(product.name), subtitle: Text( - '${_formatQty(product.quantity)} ${product.unit}', + '${_formatQty(product.quantity)} ${ref.watch(unitsProvider).valueOrNull?[product.unit] ?? product.unit}', style: theme.textTheme.bodySmall, ), trailing: Column( @@ -303,8 +304,6 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> { TextEditingController(text: widget.product.storageDays.toString()); bool _saving = false; - static const _units = ['г', 'кг', 'мл', 'л', 'шт']; - @override void dispose() { _qtyController.dispose(); @@ -340,12 +339,16 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> { ), ), const SizedBox(width: 12), - DropdownButton( - value: _units.contains(_unit) ? _unit : _units.first, - items: _units - .map((u) => DropdownMenuItem(value: u, child: Text(u))) - .toList(), - onChanged: (v) => setState(() => _unit = v!), + ref.watch(unitsProvider).when( + data: (units) => DropdownButton( + value: units.containsKey(_unit) ? _unit : units.keys.first, + items: units.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => setState(() => _unit = v!), + ), + loading: () => const SizedBox(width: 60, child: LinearProgressIndicator()), + error: (_, __) => const Text('?'), ), ], ), diff --git a/client/lib/features/recipes/recipe_detail_screen.dart b/client/lib/features/recipes/recipe_detail_screen.dart index 2dc9996..20431d7 100644 --- a/client/lib/features/recipes/recipe_detail_screen.dart +++ b/client/lib/features/recipes/recipe_detail_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/locale/unit_provider.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/models/recipe.dart'; import '../../shared/models/saved_recipe.dart'; @@ -370,13 +371,13 @@ class _TagsRow extends StatelessWidget { } } -class _IngredientsSection extends StatelessWidget { +class _IngredientsSection extends ConsumerWidget { final List ingredients; const _IngredientsSection({required this.ingredients}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (ingredients.isEmpty) return const SizedBox.shrink(); return Padding( @@ -401,7 +402,7 @@ class _IngredientsSection extends StatelessWidget { const SizedBox(width: 10), Expanded(child: Text(ing.name)), Text( - '${_formatAmount(ing.amount)} ${ing.unit}', + '${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.unit] ?? ing.unit}', style: const TextStyle( color: AppColors.textSecondary, fontSize: 13), ), diff --git a/client/lib/features/scan/recognition_confirm_screen.dart b/client/lib/features/scan/recognition_confirm_screen.dart index cd115ed..eb7d439 100644 --- a/client/lib/features/scan/recognition_confirm_screen.dart +++ b/client/lib/features/scan/recognition_confirm_screen.dart @@ -1,6 +1,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 'recognition_service.dart'; @@ -21,8 +22,6 @@ class _RecognitionConfirmScreenState late final List<_EditableItem> _items; bool _saving = false; - static const _units = ['г', 'кг', 'мл', 'л', 'шт', 'уп']; - @override void initState() { super.initState(); @@ -30,7 +29,7 @@ class _RecognitionConfirmScreenState .map((item) => _EditableItem( name: item.name, quantity: item.quantity, - unit: _mapUnit(item.unit), + unit: item.unit, category: item.category, mappingId: item.mappingId, storageDays: item.storageDays, @@ -39,27 +38,6 @@ class _RecognitionConfirmScreenState .toList(); } - String _mapUnit(String unit) { - // Backend may return 'pcs', 'g', 'kg', etc. — normalise to display units. - switch (unit.toLowerCase()) { - case 'g': - return 'г'; - case 'kg': - return 'кг'; - case 'ml': - return 'мл'; - case 'l': - return 'л'; - case 'pcs': - case 'шт': - return 'шт'; - case 'уп': - return 'уп'; - default: - return unit; - } - } - @override Widget build(BuildContext context) { return Scaffold( @@ -80,7 +58,7 @@ class _RecognitionConfirmScreenState itemCount: _items.length, itemBuilder: (_, i) => _ItemTile( item: _items[i], - units: _units, + units: ref.watch(unitsProvider).valueOrNull ?? {}, onDelete: () => setState(() => _items.removeAt(i)), onChanged: () => setState(() {}), ), @@ -173,7 +151,7 @@ class _ItemTile extends StatefulWidget { }); final _EditableItem item; - final List units; + final Map units; final VoidCallback onDelete; final VoidCallback onChanged; @@ -268,21 +246,23 @@ class _ItemTileState extends State<_ItemTile> { ), ), const SizedBox(width: 8), - DropdownButton( - value: widget.units.contains(widget.item.unit) - ? widget.item.unit - : widget.units.last, - underline: const SizedBox(), - items: widget.units - .map((u) => DropdownMenuItem(value: u, child: Text(u))) - .toList(), - onChanged: (v) { - if (v != null) { - setState(() => widget.item.unit = v); - widget.onChanged(); - } - }, - ), + widget.units.isEmpty + ? const SizedBox(width: 48) + : DropdownButton( + value: widget.units.containsKey(widget.item.unit) + ? widget.item.unit + : widget.units.keys.first, + underline: const SizedBox(), + items: widget.units.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) { + setState(() => widget.item.unit = v); + widget.onChanged(); + } + }, + ), IconButton( icon: const Icon(Icons.close), onPressed: widget.onDelete,