feat: dynamic units table with localized names via GET /units
- Add units + unit_translations tables with FK constraints on products and ingredient_mappings - Normalize products.unit from Russian strings (г, кг) to English codes (g, kg) - Load units at startup (in-memory registry) and serve via GET /units (language-aware) - Replace hardcoded _units lists and _mapUnit() functions in Flutter with unitsProvider FutureProvider - Re-fetches automatically when language changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/locale"
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/food-ai/backend/internal/menu"
|
"github.com/food-ai/backend/internal/menu"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"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/pexels"
|
||||||
"github.com/food-ai/backend/internal/product"
|
"github.com/food-ai/backend/internal/product"
|
||||||
"github.com/food-ai/backend/internal/recognition"
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
@@ -78,6 +79,11 @@ func run() error {
|
|||||||
}
|
}
|
||||||
slog.Info("languages loaded", "count", len(locale.Languages))
|
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
|
// Firebase auth
|
||||||
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/food-ai/backend/internal/language"
|
"github.com/food-ai/backend/internal/language"
|
||||||
"github.com/food-ai/backend/internal/menu"
|
"github.com/food-ai/backend/internal/menu"
|
||||||
"github.com/food-ai/backend/internal/middleware"
|
"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/product"
|
||||||
"github.com/food-ai/backend/internal/recognition"
|
"github.com/food-ai/backend/internal/recognition"
|
||||||
"github.com/food-ai/backend/internal/recommendation"
|
"github.com/food-ai/backend/internal/recommendation"
|
||||||
@@ -47,6 +48,7 @@ func NewRouter(
|
|||||||
// Public
|
// Public
|
||||||
r.Get("/health", healthCheck(pool))
|
r.Get("/health", healthCheck(pool))
|
||||||
r.Get("/languages", language.List)
|
r.Get("/languages", language.List)
|
||||||
|
r.Get("/units", units.List)
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
r.Post("/login", authHandler.Login)
|
r.Post("/login", authHandler.Login)
|
||||||
r.Post("/refresh", authHandler.Refresh)
|
r.Post("/refresh", authHandler.Refresh)
|
||||||
|
|||||||
28
backend/internal/units/handler.go
Normal file
28
backend/internal/units/handler.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
73
backend/internal/units/registry.go
Normal file
73
backend/internal/units/registry.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
58
backend/migrations/014_create_units.sql
Normal file
58
backend/migrations/014_create_units.sql
Normal file
@@ -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;
|
||||||
@@ -8,8 +8,8 @@ class LanguageRepository {
|
|||||||
LanguageRepository(this._api);
|
LanguageRepository(this._api);
|
||||||
|
|
||||||
Future<Map<String, String>> fetchLanguages() async {
|
Future<Map<String, String>> fetchLanguages() async {
|
||||||
final response = await _api.dio.get('/languages');
|
final data = await _api.get('/languages');
|
||||||
final List<dynamic> items = response.data['languages'] as List;
|
final List<dynamic> items = data['languages'] as List;
|
||||||
return {
|
return {
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
item['code'] as String: item['native_name'] as String,
|
item['code'] as String: item['native_name'] as String,
|
||||||
|
|||||||
22
client/lib/core/api/unit_repository.dart
Normal file
22
client/lib/core/api/unit_repository.dart
Normal file
@@ -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<Map<String, String>> fetchUnits() async {
|
||||||
|
final data = await _api.get('/units');
|
||||||
|
final List<dynamic> items = data['units'] as List;
|
||||||
|
return {
|
||||||
|
for (final item in items)
|
||||||
|
item['code'] as String: item['name'] as String,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final unitRepositoryProvider = Provider<UnitRepository>(
|
||||||
|
(ref) => UnitRepository(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
12
client/lib/core/locale/unit_provider.dart
Normal file
12
client/lib/core/locale/unit_provider.dart
Normal file
@@ -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<Map<String, String>>((ref) {
|
||||||
|
ref.watch(languageProvider); // invalidate when language changes
|
||||||
|
return ref.read(unitRepositoryProvider).fetchUnits();
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../../shared/models/shopping_item.dart';
|
import '../../shared/models/shopping_item.dart';
|
||||||
import 'menu_provider.dart';
|
import 'menu_provider.dart';
|
||||||
|
|
||||||
@@ -191,14 +192,14 @@ class _ShoppingTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
subtitle: item.inStock > 0
|
subtitle: item.inStock > 0
|
||||||
? Text(
|
? Text(
|
||||||
'${item.inStock.toStringAsFixed(0)} ${item.unit} есть дома',
|
'${item.inStock.toStringAsFixed(0)} ${ref.watch(unitsProvider).valueOrNull?[item.unit] ?? item.unit} есть дома',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
'$amountStr ${item.unit}',
|
'$amountStr ${ref.watch(unitsProvider).valueOrNull?[item.unit] ?? item.unit}',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../../shared/models/ingredient_mapping.dart';
|
import '../../shared/models/ingredient_mapping.dart';
|
||||||
import 'product_provider.dart';
|
import 'product_provider.dart';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
final _qtyController = TextEditingController(text: '1');
|
final _qtyController = TextEditingController(text: '1');
|
||||||
final _daysController = TextEditingController(text: '7');
|
final _daysController = TextEditingController(text: '7');
|
||||||
|
|
||||||
String _unit = 'шт';
|
String _unit = 'pcs';
|
||||||
String? _category;
|
String? _category;
|
||||||
String? _mappingId;
|
String? _mappingId;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
@@ -28,8 +29,6 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
bool _searching = false;
|
bool _searching = false;
|
||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
|
|
||||||
static const _units = ['г', 'кг', 'мл', 'л', 'шт'];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
@@ -69,8 +68,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
_mappingId = mapping.id;
|
_mappingId = mapping.id;
|
||||||
_category = mapping.category;
|
_category = mapping.category;
|
||||||
if (mapping.defaultUnit != null) {
|
if (mapping.defaultUnit != null) {
|
||||||
// Map backend unit codes to display units
|
_unit = mapping.defaultUnit!;
|
||||||
_unit = _mapUnit(mapping.defaultUnit!);
|
|
||||||
}
|
}
|
||||||
if (mapping.storageDays != null) {
|
if (mapping.storageDays != null) {
|
||||||
_daysController.text = mapping.storageDays.toString();
|
_daysController.text = mapping.storageDays.toString();
|
||||||
@@ -79,21 +77,6 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _mapUnit(String backendUnit) {
|
|
||||||
switch (backendUnit.toLowerCase()) {
|
|
||||||
case 'g':
|
|
||||||
return 'г';
|
|
||||||
case 'kg':
|
|
||||||
return 'кг';
|
|
||||||
case 'ml':
|
|
||||||
return 'мл';
|
|
||||||
case 'l':
|
|
||||||
return 'л';
|
|
||||||
default:
|
|
||||||
return 'шт';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
@@ -170,7 +153,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
? Text(_categoryLabel(m.category!))
|
? Text(_categoryLabel(m.category!))
|
||||||
: null,
|
: null,
|
||||||
trailing: m.defaultUnit != null
|
trailing: m.defaultUnit != null
|
||||||
? Text(m.defaultUnit!,
|
? Text(
|
||||||
|
ref.watch(unitsProvider).valueOrNull?[m.defaultUnit!] ?? m.defaultUnit!,
|
||||||
style:
|
style:
|
||||||
Theme.of(context).textTheme.bodySmall)
|
Theme.of(context).textTheme.bodySmall)
|
||||||
: null,
|
: null,
|
||||||
@@ -197,15 +181,18 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
DropdownButtonHideUnderline(
|
ref.watch(unitsProvider).when(
|
||||||
child: DropdownButton<String>(
|
data: (units) => DropdownButtonHideUnderline(
|
||||||
value: _units.contains(_unit) ? _unit : _units.last,
|
child: DropdownButton<String>(
|
||||||
items: _units
|
value: units.containsKey(_unit) ? _unit : units.keys.first,
|
||||||
.map((u) =>
|
items: units.entries
|
||||||
DropdownMenuItem(value: u, child: Text(u)))
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) => setState(() => _unit = v!),
|
onChanged: (v) => setState(() => _unit = v!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
loading: () => const SizedBox(width: 60, child: LinearProgressIndicator()),
|
||||||
|
error: (_, __) => const Text('?'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../../shared/models/product.dart';
|
import '../../shared/models/product.dart';
|
||||||
import 'product_provider.dart';
|
import 'product_provider.dart';
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ class _ProductTile extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
title: Text(product.name),
|
title: Text(product.name),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_formatQty(product.quantity)} ${product.unit}',
|
'${_formatQty(product.quantity)} ${ref.watch(unitsProvider).valueOrNull?[product.unit] ?? product.unit}',
|
||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
trailing: Column(
|
trailing: Column(
|
||||||
@@ -303,8 +304,6 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
|
|||||||
TextEditingController(text: widget.product.storageDays.toString());
|
TextEditingController(text: widget.product.storageDays.toString());
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
static const _units = ['г', 'кг', 'мл', 'л', 'шт'];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_qtyController.dispose();
|
_qtyController.dispose();
|
||||||
@@ -340,12 +339,16 @@ class _EditProductSheetState extends ConsumerState<_EditProductSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
DropdownButton<String>(
|
ref.watch(unitsProvider).when(
|
||||||
value: _units.contains(_unit) ? _unit : _units.first,
|
data: (units) => DropdownButton<String>(
|
||||||
items: _units
|
value: units.containsKey(_unit) ? _unit : units.keys.first,
|
||||||
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
|
items: units.entries
|
||||||
.toList(),
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
onChanged: (v) => setState(() => _unit = v!),
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _unit = v!),
|
||||||
|
),
|
||||||
|
loading: () => const SizedBox(width: 60, child: LinearProgressIndicator()),
|
||||||
|
error: (_, __) => const Text('?'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../shared/models/recipe.dart';
|
import '../../shared/models/recipe.dart';
|
||||||
import '../../shared/models/saved_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<RecipeIngredient> ingredients;
|
final List<RecipeIngredient> ingredients;
|
||||||
|
|
||||||
const _IngredientsSection({required this.ingredients});
|
const _IngredientsSection({required this.ingredients});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (ingredients.isEmpty) return const SizedBox.shrink();
|
if (ingredients.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -401,7 +402,7 @@ class _IngredientsSection extends StatelessWidget {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: Text(ing.name)),
|
Expanded(child: Text(ing.name)),
|
||||||
Text(
|
Text(
|
||||||
'${_formatAmount(ing.amount)} ${ing.unit}',
|
'${_formatAmount(ing.amount)} ${ref.watch(unitsProvider).valueOrNull?[ing.unit] ?? ing.unit}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textSecondary, fontSize: 13),
|
color: AppColors.textSecondary, fontSize: 13),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/locale/unit_provider.dart';
|
||||||
import '../products/product_provider.dart';
|
import '../products/product_provider.dart';
|
||||||
import 'recognition_service.dart';
|
import 'recognition_service.dart';
|
||||||
|
|
||||||
@@ -21,8 +22,6 @@ class _RecognitionConfirmScreenState
|
|||||||
late final List<_EditableItem> _items;
|
late final List<_EditableItem> _items;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
static const _units = ['г', 'кг', 'мл', 'л', 'шт', 'уп'];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -30,7 +29,7 @@ class _RecognitionConfirmScreenState
|
|||||||
.map((item) => _EditableItem(
|
.map((item) => _EditableItem(
|
||||||
name: item.name,
|
name: item.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: _mapUnit(item.unit),
|
unit: item.unit,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
mappingId: item.mappingId,
|
mappingId: item.mappingId,
|
||||||
storageDays: item.storageDays,
|
storageDays: item.storageDays,
|
||||||
@@ -39,27 +38,6 @@ class _RecognitionConfirmScreenState
|
|||||||
.toList();
|
.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -80,7 +58,7 @@ class _RecognitionConfirmScreenState
|
|||||||
itemCount: _items.length,
|
itemCount: _items.length,
|
||||||
itemBuilder: (_, i) => _ItemTile(
|
itemBuilder: (_, i) => _ItemTile(
|
||||||
item: _items[i],
|
item: _items[i],
|
||||||
units: _units,
|
units: ref.watch(unitsProvider).valueOrNull ?? {},
|
||||||
onDelete: () => setState(() => _items.removeAt(i)),
|
onDelete: () => setState(() => _items.removeAt(i)),
|
||||||
onChanged: () => setState(() {}),
|
onChanged: () => setState(() {}),
|
||||||
),
|
),
|
||||||
@@ -173,7 +151,7 @@ class _ItemTile extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final _EditableItem item;
|
final _EditableItem item;
|
||||||
final List<String> units;
|
final Map<String, String> units;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback onChanged;
|
final VoidCallback onChanged;
|
||||||
|
|
||||||
@@ -268,21 +246,23 @@ class _ItemTileState extends State<_ItemTile> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
DropdownButton<String>(
|
widget.units.isEmpty
|
||||||
value: widget.units.contains(widget.item.unit)
|
? const SizedBox(width: 48)
|
||||||
? widget.item.unit
|
: DropdownButton<String>(
|
||||||
: widget.units.last,
|
value: widget.units.containsKey(widget.item.unit)
|
||||||
underline: const SizedBox(),
|
? widget.item.unit
|
||||||
items: widget.units
|
: widget.units.keys.first,
|
||||||
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
|
underline: const SizedBox(),
|
||||||
.toList(),
|
items: widget.units.entries
|
||||||
onChanged: (v) {
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
if (v != null) {
|
.toList(),
|
||||||
setState(() => widget.item.unit = v);
|
onChanged: (v) {
|
||||||
widget.onChanged();
|
if (v != null) {
|
||||||
}
|
setState(() => widget.item.unit = v);
|
||||||
},
|
widget.onChanged();
|
||||||
),
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: widget.onDelete,
|
onPressed: widget.onDelete,
|
||||||
|
|||||||
Reference in New Issue
Block a user