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:
dbastrikin
2026-03-15 16:15:33 +02:00
parent e1fbe7b1a2
commit 55d01400b0
13 changed files with 259 additions and 86 deletions

View File

@@ -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<String> units;
final Map<String, String> units;
final VoidCallback onDelete;
final VoidCallback onChanged;
@@ -268,21 +246,23 @@ class _ItemTileState extends State<_ItemTile> {
),
),
const SizedBox(width: 8),
DropdownButton<String>(
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<String>(
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,