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

@@ -8,8 +8,8 @@ class LanguageRepository {
LanguageRepository(this._api);
Future<Map<String, String>> fetchLanguages() async {
final response = await _api.dio.get('/languages');
final List<dynamic> items = response.data['languages'] as List;
final data = await _api.get('/languages');
final List<dynamic> items = data['languages'] as List;
return {
for (final item in items)
item['code'] as String: item['native_name'] as String,

View 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)),
);

View 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();
});

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 '../../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,
),

View File

@@ -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<AddProductScreen> {
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<AddProductScreen> {
bool _searching = false;
Timer? _debounce;
static const _units = ['г', 'кг', 'мл', 'л', 'шт'];
@override
void dispose() {
_nameController.dispose();
@@ -69,8 +68,7 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
_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<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 {
final name = _nameController.text.trim();
if (name.isEmpty) {
@@ -170,7 +153,8 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
? 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<AddProductScreen> {
),
),
const SizedBox(width: 12),
DropdownButtonHideUnderline(
child: DropdownButton<String>(
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<String>(
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('?'),
),
],
),

View File

@@ -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<String>(
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<String>(
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('?'),
),
],
),

View File

@@ -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<RecipeIngredient> 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),
),

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,