feat: Flutter client localisation (12 languages)

Add flutter_localizations + intl, 12 ARB files (en/ru/es/de/fr/it/pt/zh/ja/ko/ar/hi),
replace all hardcoded Russian UI strings with AppLocalizations, detect system locale
on first launch, localise bottom nav bar labels, document rule in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-19 22:22:52 +02:00
parent 9357c194eb
commit 54b10d51e2
40 changed files with 5919 additions and 267 deletions

View File

@@ -56,3 +56,23 @@ final todayJobsProvider =
StateNotifierProvider<TodayJobsNotifier, AsyncValue<List<DishJobSummary>>>(
(ref) => TodayJobsNotifier(ref.read(recognitionServiceProvider)),
);
// ── All recognition jobs (history screen) ─────────────────────
class AllJobsNotifier extends StateNotifier<AsyncValue<List<DishJobSummary>>> {
final RecognitionService _service;
AllJobsNotifier(this._service) : super(const AsyncValue.loading()) {
load();
}
Future<void> load() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _service.listAllJobs());
}
}
final allJobsProvider =
StateNotifierProvider<AllJobsNotifier, AsyncValue<List<DishJobSummary>>>(
(ref) => AllJobsNotifier(ref.read(recognitionServiceProvider)),
);

View File

@@ -2,9 +2,11 @@ import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import '../../core/storage/local_preferences_provider.dart';
import '../../core/theme/app_colors.dart';
@@ -24,6 +26,7 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final homeSummaryState = ref.watch(homeProvider);
final profile = ref.watch(profileProvider).valueOrNull;
final userName = profile?.name;
@@ -100,7 +103,7 @@ class HomeScreen extends ConsumerWidget {
_QuickActionsRow(),
if (recommendations.isNotEmpty) ...[
const SizedBox(height: 20),
_SectionTitle('Рекомендуем приготовить'),
_SectionTitle(l10n.recommendCook),
const SizedBox(height: 12),
_RecommendationsRow(recipes: recommendations),
],
@@ -120,26 +123,25 @@ class _AppBar extends StatelessWidget {
final String? userName;
const _AppBar({this.userName});
String get _greetingBase {
String _greetingBase(AppLocalizations l10n) {
final hour = DateTime.now().hour;
if (hour < 12) return 'Доброе утро';
if (hour < 18) return 'Добрый день';
return 'Добрый вечер';
}
String get _greeting {
final name = userName;
if (name != null && name.isNotEmpty) return '$_greetingBase, $name!';
return _greetingBase;
if (hour < 12) return l10n.greetingMorning;
if (hour < 18) return l10n.greetingAfternoon;
return l10n.greetingEvening;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final base = _greetingBase(l10n);
final greeting = (userName != null && userName!.isNotEmpty)
? '$base, $userName!'
: base;
return SliverAppBar(
pinned: false,
floating: true,
title: Text(_greeting, style: theme.textTheme.titleMedium),
title: Text(greeting, style: theme.textTheme.titleMedium),
);
}
}
@@ -160,11 +162,6 @@ class _DateSelector extends StatefulWidget {
}
class _DateSelectorState extends State<_DateSelector> {
static const _weekDayShort = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
static const _monthNames = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
];
// Total days available in the past (index 0 = today, index N-1 = oldest)
static const _totalDays = 365;
static const _pillWidth = 48.0;
@@ -172,12 +169,10 @@ class _DateSelectorState extends State<_DateSelector> {
late final ScrollController _scrollController;
String _formatSelectedDate(DateTime date) {
String _formatSelectedDate(DateTime date, String localeCode) {
final now = DateTime.now();
final dayName = _weekDayShort[date.weekday - 1];
final month = _monthNames[date.month - 1];
final yearSuffix = date.year != now.year ? ' ${date.year}' : '';
return '$dayName, ${date.day} $month$yearSuffix';
return DateFormat('EEE, d MMMM', localeCode).format(date) + yearSuffix;
}
// Index in the reversed list: 0 = today, 1 = yesterday, …
@@ -253,6 +248,8 @@ class _DateSelectorState extends State<_DateSelector> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final localeCode = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
@@ -276,7 +273,7 @@ class _DateSelectorState extends State<_DateSelector> {
),
Expanded(
child: Text(
_formatSelectedDate(widget.selectedDate),
_formatSelectedDate(widget.selectedDate, localeCode),
style: theme.textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
@@ -290,7 +287,7 @@ class _DateSelectorState extends State<_DateSelector> {
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
'Сегодня',
l10n.today,
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary),
),
@@ -336,7 +333,7 @@ class _DateSelectorState extends State<_DateSelector> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_weekDayShort[date.weekday - 1],
DateFormat('EEE', localeCode).format(date),
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
@@ -386,21 +383,22 @@ class _CaloriesCard extends StatelessWidget {
Widget build(BuildContext context) {
if (dailyGoal == 0) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final logged = loggedCalories.toInt();
final rawProgress = dailyGoal > 0 ? loggedCalories / dailyGoal : 0.0;
final isOverGoal = rawProgress > 1.0;
final ringColor = _ringColorFor(rawProgress, goalType);
final String secondaryLabel;
final String secondaryValue;
final Color secondaryColor;
if (isOverGoal) {
final overBy = (loggedCalories - dailyGoal).toInt();
secondaryLabel = '+$overBy перебор';
secondaryValue = '+$overBy ${l10n.caloriesUnit}';
secondaryColor = AppColors.error;
} else {
final remaining = (dailyGoal - loggedCalories).toInt();
secondaryLabel = 'осталось $remaining';
secondaryValue = '$remaining ${l10n.caloriesUnit}';
secondaryColor = AppColors.textSecondary;
}
@@ -433,14 +431,14 @@ class _CaloriesCard extends StatelessWidget {
),
),
Text(
'ккал',
l10n.caloriesUnit,
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
'цель: $dailyGoal',
'${l10n.goalLabel} $dailyGoal',
style: theme.textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
@@ -456,20 +454,20 @@ class _CaloriesCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CalorieStat(
label: 'Потреблено',
value: '$logged ккал',
label: l10n.consumed,
value: '$logged ${l10n.caloriesUnit}',
valueColor: ringColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: isOverGoal ? 'Превышение' : 'Осталось',
value: secondaryLabel,
label: isOverGoal ? l10n.exceeded : l10n.remaining,
value: secondaryValue,
valueColor: secondaryColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: 'Цель',
value: '$dailyGoal ккал',
label: l10n.goalLabel.replaceAll(':', '').trim(),
value: '$dailyGoal ${l10n.caloriesUnit}',
valueColor: AppColors.textPrimary,
),
],
@@ -532,28 +530,29 @@ class _MacrosRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: _MacroChip(
label: 'Белки',
value: '${proteinG.toStringAsFixed(1)} г',
label: l10n.proteinLabel,
value: '${proteinG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.blue,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: 'Жиры',
value: '${fatG.toStringAsFixed(1)} г',
label: l10n.fatLabel,
value: '${fatG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.orange,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: 'Углеводы',
value: '${carbsG.toStringAsFixed(1)} г',
label: l10n.carbsLabel,
value: '${carbsG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.green,
),
),
@@ -712,11 +711,12 @@ class _DailyMealsSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Приёмы пищи', style: theme.textTheme.titleSmall),
Text(l10n.mealsSection, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...mealTypeIds.map((mealTypeId) {
final mealTypeOption = mealTypeById(mealTypeId);
@@ -743,6 +743,8 @@ Future<void> _pickAndShowDishResult(
WidgetRef ref,
String mealTypeId,
) async {
final l10n = AppLocalizations.of(context)!;
// 1. Choose image source.
final source = await showModalBottomSheet<ImageSource>(
context: context,
@@ -752,12 +754,12 @@ Future<void> _pickAndShowDishResult(
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Камера'),
title: Text(l10n.camera),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Галерея'),
title: Text(l10n.gallery),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
@@ -778,7 +780,7 @@ Future<void> _pickAndShowDishResult(
// 3. Show progress dialog.
// Capture root navigator before await to avoid GoRouter inner-navigator issues.
final rootNavigator = Navigator.of(context, rootNavigator: true);
final progressNotifier = _DishProgressNotifier();
final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto);
showDialog(
context: context,
barrierDismissible: false,
@@ -809,11 +811,11 @@ Future<void> _pickAndShowDishResult(
switch (event) {
case DishJobQueued():
progressNotifier.update(
message: 'Вы в очереди #${event.position + 1} · ~${event.estimatedSeconds} сек',
message: '${l10n.inQueue} · ${l10n.queuePosition(event.position + 1)}',
showUpgrade: event.position > 0,
);
case DishJobProcessing():
progressNotifier.update(message: 'Обрабатываем...');
progressNotifier.update(message: l10n.processing);
case DishJobDone():
rootNavigator.pop(); // close dialog
if (!context.mounted) return;
@@ -825,6 +827,7 @@ Future<void> _pickAndShowDishResult(
dish: event.result,
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
jobId: jobCreated.jobId,
targetDate: targetDate,
onAdded: () => Navigator.pop(sheetContext),
),
);
@@ -836,7 +839,7 @@ Future<void> _pickAndShowDishResult(
SnackBar(
content: Text(event.error),
action: SnackBarAction(
label: 'Повторить',
label: l10n.retry,
onPressed: () => _pickAndShowDishResult(context, ref, mealTypeId),
),
),
@@ -849,9 +852,7 @@ Future<void> _pickAndShowDishResult(
if (context.mounted) {
rootNavigator.pop(); // close dialog
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось распознать. Попробуйте ещё раз.'),
),
SnackBar(content: Text(l10n.recognitionFailed)),
);
}
}
@@ -872,7 +873,10 @@ class _DishProgressState {
}
class _DishProgressNotifier extends ChangeNotifier {
_DishProgressState _state = const _DishProgressState(message: 'Анализируем фото...');
late _DishProgressState _state;
_DishProgressNotifier({required String initialMessage})
: _state = _DishProgressState(message: initialMessage);
_DishProgressState get state => _state;
@@ -889,6 +893,7 @@ class _DishProgressDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: notifier,
builder: (context, _) {
@@ -903,7 +908,7 @@ class _DishProgressDialog extends StatelessWidget {
if (state.showUpgrade) ...[
const SizedBox(height: 12),
Text(
'Хотите без очереди? Upgrade →',
l10n.upgradePrompt,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
@@ -931,6 +936,7 @@ class _MealCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final totalCalories = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.calories ?? 0));
@@ -946,12 +952,12 @@ class _MealCard extends ConsumerWidget {
Text(mealTypeOption.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(mealTypeOption.label,
Text(mealTypeLabel(mealTypeOption.id, l10n),
style: theme.textTheme.titleSmall),
const Spacer(),
if (totalCalories > 0)
Text(
'${totalCalories.toInt()} ккал',
'${totalCalories.toInt()} ${l10n.caloriesUnit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -959,7 +965,7 @@ class _MealCard extends ConsumerWidget {
IconButton(
icon: const Icon(Icons.add, size: 20),
visualDensity: VisualDensity.compact,
tooltip: 'Добавить блюдо',
tooltip: l10n.addDish,
onPressed: () => _pickAndShowDishResult(
context, ref, mealTypeOption.id),
),
@@ -990,6 +996,7 @@ class _DiaryEntryTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final calories = entry.calories?.toInt();
final hasProtein = (entry.proteinG ?? 0) > 0;
@@ -1016,7 +1023,7 @@ class _DiaryEntryTile extends StatelessWidget {
children: [
if (calories != null)
Text(
'$calories ккал',
'$calories ${l10n.caloriesUnit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -1095,6 +1102,7 @@ class _TodayJobsWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final visibleJobs = jobs.take(3).toList();
final hasMore = jobs.length > 3;
@@ -1104,7 +1112,7 @@ class _TodayJobsWidget extends ConsumerWidget {
children: [
Row(
children: [
Text('Распознавания', style: theme.textTheme.titleSmall),
Text(l10n.dishRecognition, style: theme.textTheme.titleSmall),
const Spacer(),
if (hasMore)
TextButton(
@@ -1114,7 +1122,7 @@ class _TodayJobsWidget extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
'Все',
l10n.all,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
@@ -1139,6 +1147,7 @@ class _JobTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDone = job.status == 'done';
@@ -1162,13 +1171,13 @@ class _JobTile extends ConsumerWidget {
final dishName = job.result?.candidates.isNotEmpty == true
? job.result!.best.dishName
: null;
final subtitle = dishName ?? (isFailed ? (job.error ?? 'Ошибка') : 'Обрабатывается…');
final subtitle = dishName ?? (isFailed ? (job.error ?? l10n.recognitionError) : l10n.recognizing);
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(
dishName ?? (isProcessing ? 'Распознаётся…' : 'Ошибка'),
dishName ?? (isProcessing ? l10n.recognizing : l10n.recognitionError),
style: theme.textTheme.bodyMedium,
),
subtitle: Text(
@@ -1193,6 +1202,8 @@ class _JobTile extends ConsumerWidget {
dish: job.result!,
preselectedMealType: job.targetMealType,
jobId: job.id,
targetDate: job.targetDate,
createdAt: job.createdAt,
onAdded: () => Navigator.pop(sheetContext),
),
);
@@ -1210,12 +1221,13 @@ class _QuickActionsRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: _ActionButton(
icon: Icons.document_scanner_outlined,
label: 'Сканировать',
label: l10n.scanDish,
onTap: () => context.push('/scan'),
),
),
@@ -1223,7 +1235,7 @@ class _QuickActionsRow extends StatelessWidget {
Expanded(
child: _ActionButton(
icon: Icons.calendar_month_outlined,
label: 'Меню',
label: l10n.menu,
onTap: () => context.push('/menu'),
),
),
@@ -1231,7 +1243,7 @@ class _QuickActionsRow extends StatelessWidget {
Expanded(
child: _ActionButton(
icon: Icons.history,
label: 'История',
label: l10n.dishHistory,
onTap: () => context.push('/scan/history'),
),
),
@@ -1309,6 +1321,7 @@ class _RecipeCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return SizedBox(
@@ -1343,7 +1356,7 @@ class _RecipeCard extends StatelessWidget {
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text(
'${recipe.calories!.toInt()} ккал',
'${recipe.calories!.toInt()} ${l10n.caloriesUnit}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_colors.dart';
@@ -1151,6 +1152,7 @@ class _MealTypesStepContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1178,7 +1180,7 @@ class _MealTypesStepContent extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
mealTypeOption.label,
mealTypeLabel(mealTypeOption.id, l10n),
style: theme.textTheme.bodyLarge?.copyWith(
color: isSelected
? accentColor

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
@@ -15,16 +16,17 @@ class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final state = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Профиль'),
title: Text(l10n.profileTitle),
actions: [
if (state.hasValue)
TextButton(
onPressed: () => _openEdit(context, state.value!),
child: const Text('Изменить'),
child: Text(l10n.edit),
),
],
),
@@ -33,7 +35,7 @@ class ProfileScreen extends ConsumerWidget {
error: (_, __) => Center(
child: FilledButton(
onPressed: () => ref.read(profileProvider.notifier).load(),
child: const Text('Повторить'),
child: Text(l10n.retry),
),
),
data: (user) => _ProfileBody(user: user),
@@ -58,6 +60,28 @@ class _ProfileBody extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
String? genderLabel(String? gender) => switch (gender) {
'male' => l10n.genderMale,
'female' => l10n.genderFemale,
_ => null,
};
String? goalLabel(String? goal) => switch (goal) {
'lose' => l10n.goalLoss,
'maintain' => l10n.goalMaintain,
'gain' => l10n.goalGain,
_ => null,
};
String? activityLabel(String? activity) => switch (activity) {
'low' => l10n.activityLow,
'moderate' => l10n.activityMedium,
'high' => l10n.activityHigh,
_ => null,
};
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
@@ -65,50 +89,49 @@ class _ProfileBody extends ConsumerWidget {
const SizedBox(height: 24),
// Body params
_SectionLabel('ПАРАМЕТРЫ ТЕЛА'),
_SectionLabel(l10n.bodyParams),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow('Рост', user.heightCm != null ? '${user.heightCm} см' : null),
_InfoRow(l10n.height, user.heightCm != null ? '${user.heightCm} см' : null),
const Divider(height: 1, indent: 16),
_InfoRow('Вес',
_InfoRow(l10n.weight,
user.weightKg != null ? '${_fmt(user.weightKg!)} кг' : null),
const Divider(height: 1, indent: 16),
_InfoRow('Возраст', user.age != null ? '${user.age} лет' : null),
_InfoRow(l10n.age, user.age != null ? '${user.age}' : null),
const Divider(height: 1, indent: 16),
_InfoRow('Пол', _genderLabel(user.gender)),
_InfoRow(l10n.gender, genderLabel(user.gender)),
]),
if (user.heightCm == null && user.weightKg == null) ...[
const SizedBox(height: 8),
_HintBanner('Укажите параметры тела для расчёта нормы калорий'),
_HintBanner(l10n.calorieHint),
],
const SizedBox(height: 16),
// Goal & activity
_SectionLabel('ЦЕЛЬ И АКТИВНОСТЬ'),
_SectionLabel(l10n.goalActivity),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow('Цель', _goalLabel(user.goal)),
_InfoRow(l10n.goalLabel.replaceAll(':', '').trim(), goalLabel(user.goal)),
const Divider(height: 1, indent: 16),
_InfoRow('Активность', _activityLabel(user.activity)),
_InfoRow('Активность', activityLabel(user.activity)),
]),
const SizedBox(height: 16),
// Calories + meal types
_SectionLabel('ПИТАНИЕ'),
_SectionLabel(l10n.nutrition),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(
'Норма калорий',
l10n.calorieGoal,
user.dailyCalories != null
? '${user.dailyCalories} ккал/день'
? '${user.dailyCalories} ${l10n.caloriesUnit}/день'
: null,
),
const Divider(height: 1, indent: 16),
_InfoRow(
'Приёмы пищи',
l10n.mealTypes,
user.mealTypes
.map((mealTypeId) =>
mealTypeById(mealTypeId)?.label ?? mealTypeId)
.map((mealTypeId) => mealTypeLabel(mealTypeId, l10n))
.join(', '),
),
]),
@@ -117,7 +140,7 @@ class _ProfileBody extends ConsumerWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Рассчитано по формуле Миффлина-Сан Жеора',
l10n.formulaNote,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
@@ -127,11 +150,11 @@ class _ProfileBody extends ConsumerWidget {
const SizedBox(height: 16),
// Settings
_SectionLabel('НАСТРОЙКИ'),
_SectionLabel(l10n.settings),
const SizedBox(height: 6),
_InfoCard(children: [
_InfoRow(
'Язык',
l10n.language,
ref.watch(supportedLanguagesProvider).valueOrNull?[
user.preferences['language'] as String? ?? 'ru'] ??
'Русский',
@@ -144,28 +167,10 @@ class _ProfileBody extends ConsumerWidget {
);
}
static String _fmt(double w) =>
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
static String? _genderLabel(String? g) => switch (g) {
'male' => 'Мужской',
'female' => 'Женский',
_ => null,
};
static String? _goalLabel(String? g) => switch (g) {
'lose' => 'Похудение',
'maintain' => 'Поддержание',
'gain' => 'Набор массы',
_ => null,
};
static String? _activityLabel(String? a) => switch (a) {
'low' => 'Низкая',
'moderate' => 'Средняя',
'high' => 'Высокая',
_ => null,
};
static String _fmt(double weight) =>
weight == weight.truncateToDouble()
? weight.toInt().toString()
: weight.toStringAsFixed(1);
}
// ── Header ────────────────────────────────────────────────────
@@ -241,6 +246,7 @@ class _InfoRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
@@ -249,7 +255,7 @@ class _InfoRow extends StatelessWidget {
Text(label, style: theme.textTheme.bodyMedium),
const Spacer(),
Text(
value ?? 'Не задано',
value ?? l10n.notSet,
style: theme.textTheme.bodyMedium?.copyWith(
color: value != null
? AppColors.textPrimary
@@ -296,31 +302,33 @@ class _HintBanner extends StatelessWidget {
class _LogoutButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
),
onPressed: () => _confirmLogout(context, ref),
child: const Text('Выйти из аккаунта'),
child: Text(l10n.logout),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Выйти из аккаунта?'),
builder: (dialogContext) => AlertDialog(
title: Text('${l10n.logout}?'),
content: const Text('Вы будете перенаправлены на экран входа.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: AppColors.error),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Выйти'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.logout),
),
],
),
@@ -343,9 +351,9 @@ class EditProfileSheet extends ConsumerStatefulWidget {
class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _heightCtrl;
late final TextEditingController _weightCtrl;
late final TextEditingController _nameController;
late final TextEditingController _heightController;
late final TextEditingController _weightController;
DateTime? _selectedDob;
String? _gender;
String? _goal;
@@ -357,30 +365,32 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
@override
void initState() {
super.initState();
final u = widget.user;
_nameCtrl = TextEditingController(text: u.name);
_heightCtrl = TextEditingController(text: u.heightCm?.toString() ?? '');
_weightCtrl = TextEditingController(
text: u.weightKg != null ? _fmt(u.weightKg!) : '');
final user = widget.user;
_nameController = TextEditingController(text: user.name);
_heightController = TextEditingController(text: user.heightCm?.toString() ?? '');
_weightController = TextEditingController(
text: user.weightKg != null ? _fmt(user.weightKg!) : '');
_selectedDob =
u.dateOfBirth != null ? DateTime.tryParse(u.dateOfBirth!) : null;
_gender = u.gender;
_goal = u.goal;
_activity = u.activity;
_language = u.preferences['language'] as String? ?? 'ru';
_mealTypes = List<String>.from(u.mealTypes);
user.dateOfBirth != null ? DateTime.tryParse(user.dateOfBirth!) : null;
_gender = user.gender;
_goal = user.goal;
_activity = user.activity;
_language = user.preferences['language'] as String? ?? 'ru';
_mealTypes = List<String>.from(user.mealTypes);
}
@override
void dispose() {
_nameCtrl.dispose();
_heightCtrl.dispose();
_weightCtrl.dispose();
_nameController.dispose();
_heightController.dispose();
_weightController.dispose();
super.dispose();
}
String _fmt(double w) =>
w == w.truncateToDouble() ? w.toInt().toString() : w.toStringAsFixed(1);
String _fmt(double weight) =>
weight == weight.truncateToDouble()
? weight.toInt().toString()
: weight.toStringAsFixed(1);
Future<void> _pickDob() async {
final now = DateTime.now();
@@ -395,13 +405,14 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
}
Future<void> _save() async {
final l10n = AppLocalizations.of(context)!;
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final req = UpdateProfileRequest(
name: _nameCtrl.text.trim(),
heightCm: int.tryParse(_heightCtrl.text),
weightKg: double.tryParse(_weightCtrl.text),
final request = UpdateProfileRequest(
name: _nameController.text.trim(),
heightCm: int.tryParse(_heightController.text),
weightKg: double.tryParse(_weightController.text),
dateOfBirth: _selectedDob != null
? '${_selectedDob!.year.toString().padLeft(4, '0')}-'
'${_selectedDob!.month.toString().padLeft(2, '0')}-'
@@ -414,7 +425,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
mealTypes: _mealTypes,
);
final ok = await ref.read(profileProvider.notifier).update(req);
final ok = await ref.read(profileProvider.notifier).update(request);
if (!mounted) return;
setState(() => _saving = false);
@@ -422,17 +433,18 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
if (ok) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Профиль обновлён')),
SnackBar(content: Text(l10n.profileUpdated)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось сохранить')),
SnackBar(content: Text(l10n.profileSaveFailed)),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
@@ -459,12 +471,11 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('Редактировать профиль',
style: theme.textTheme.titleMedium),
Text(l10n.editProfile, style: theme.textTheme.titleMedium),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
child: Text(l10n.cancel),
),
],
),
@@ -479,11 +490,13 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
children: [
// Name
TextFormField(
controller: _nameCtrl,
decoration: const InputDecoration(labelText: 'Имя'),
controller: _nameController,
decoration: InputDecoration(labelText: l10n.nameLabel),
textCapitalization: TextCapitalization.words,
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Введите имя' : null,
validator: (value) =>
(value == null || value.trim().isEmpty)
? l10n.nameRequired
: null,
),
const SizedBox(height: 12),
@@ -492,17 +505,16 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
children: [
Expanded(
child: TextFormField(
controller: _heightCtrl,
decoration:
const InputDecoration(labelText: 'Рост (см)'),
controller: _heightController,
decoration: InputDecoration(labelText: l10n.heightCm),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
validator: (v) {
if (v == null || v.isEmpty) return null;
final n = int.tryParse(v);
if (n == null || n < 100 || n > 250) {
validator: (value) {
if (value == null || value.isEmpty) return null;
final number = int.tryParse(value);
if (number == null || number < 100 || number > 250) {
return '100250';
}
return null;
@@ -512,19 +524,18 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _weightCtrl,
decoration:
const InputDecoration(labelText: 'Вес (кг)'),
controller: _weightController,
decoration: InputDecoration(labelText: l10n.weightKg),
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9.]'))
],
validator: (v) {
if (v == null || v.isEmpty) return null;
final n = double.tryParse(v);
if (n == null || n < 30 || n > 300) {
validator: (value) {
if (value == null || value.isEmpty) return null;
final number = double.tryParse(value);
if (number == null || number < 30 || number > 300) {
return '30300';
}
return null;
@@ -539,14 +550,13 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
InkWell(
onTap: _pickDob,
child: InputDecorator(
decoration:
const InputDecoration(labelText: 'Дата рождения'),
decoration: InputDecoration(labelText: l10n.birthDate),
child: Text(
_selectedDob != null
? '${_selectedDob!.day.toString().padLeft(2, '0')}.'
'${_selectedDob!.month.toString().padLeft(2, '0')}.'
'${_selectedDob!.year}'
: 'Не задано',
: l10n.notSet,
style: Theme.of(context).textTheme.bodyMedium,
),
),
@@ -554,34 +564,34 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
const SizedBox(height: 20),
// Gender
Text('Пол', style: theme.textTheme.labelMedium),
Text(l10n.gender, style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'male', label: Text('Мужской')),
ButtonSegment(value: 'female', label: Text('Женский')),
segments: [
ButtonSegment(value: 'male', label: Text(l10n.genderMale)),
ButtonSegment(value: 'female', label: Text(l10n.genderFemale)),
],
selected: _gender != null ? {_gender!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (s) =>
setState(() => _gender = s.isEmpty ? null : s.first),
onSelectionChanged: (selection) =>
setState(() => _gender = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
// Goal
Text('Цель', style: theme.textTheme.labelMedium),
Text(l10n.goalLabel.replaceAll(':', '').trim(),
style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'lose', label: Text('Похудение')),
ButtonSegment(
value: 'maintain', label: Text('Поддержание')),
ButtonSegment(value: 'gain', label: Text('Набор')),
segments: [
ButtonSegment(value: 'lose', label: Text(l10n.goalLoss)),
ButtonSegment(value: 'maintain', label: Text(l10n.goalMaintain)),
ButtonSegment(value: 'gain', label: Text(l10n.goalGain)),
],
selected: _goal != null ? {_goal!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (s) =>
setState(() => _goal = s.isEmpty ? null : s.first),
onSelectionChanged: (selection) =>
setState(() => _goal = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
@@ -589,21 +599,20 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
Text('Активность', style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'low', label: Text('Низкая')),
ButtonSegment(
value: 'moderate', label: Text('Средняя')),
ButtonSegment(value: 'high', label: Text('Высокая')),
segments: [
ButtonSegment(value: 'low', label: Text(l10n.activityLow)),
ButtonSegment(value: 'moderate', label: Text(l10n.activityMedium)),
ButtonSegment(value: 'high', label: Text(l10n.activityHigh)),
],
selected: _activity != null ? {_activity!} : const {},
emptySelectionAllowed: true,
onSelectionChanged: (s) => setState(
() => _activity = s.isEmpty ? null : s.first),
onSelectionChanged: (selection) => setState(
() => _activity = selection.isEmpty ? null : selection.first),
),
const SizedBox(height: 20),
// Meal types
Text('Приёмы пищи', style: theme.textTheme.labelMedium),
Text(l10n.mealTypes, style: theme.textTheme.labelMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
@@ -613,7 +622,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
_mealTypes.contains(mealTypeOption.id);
return FilterChip(
label: Text(
'${mealTypeOption.emoji} ${mealTypeOption.label}'),
'${mealTypeOption.emoji} ${mealTypeLabel(mealTypeOption.id, l10n)}'),
selected: isSelected,
onSelected: (selected) {
setState(() {
@@ -632,21 +641,20 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
// Language
ref.watch(supportedLanguagesProvider).when(
data: (languages) => DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: 'Язык интерфейса'),
decoration: InputDecoration(labelText: l10n.language),
initialValue: _language,
items: languages.entries
.map((e) => DropdownMenuItem(
value: e.key,
child: Text(e.value),
.map((entry) => DropdownMenuItem(
value: entry.key,
child: Text(entry.value),
))
.toList(),
onChanged: (v) => setState(() => _language = v),
onChanged: (value) => setState(() => _language = value),
),
loading: () => const Center(
child: CircularProgressIndicator()),
error: (_, __) =>
const Text('Не удалось загрузить языки'),
Text(l10n.historyLoadError),
),
const SizedBox(height: 32),
@@ -660,7 +668,7 @@ class _EditProfileSheetState extends ConsumerState<EditProfileSheet> {
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Text('Сохранить'),
: Text(l10n.save),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/storage/local_preferences_provider.dart';
@@ -17,6 +18,8 @@ class DishResultSheet extends ConsumerStatefulWidget {
required this.onAdded,
this.preselectedMealType,
this.jobId,
this.targetDate,
this.createdAt,
});
final DishResult dish;
@@ -24,6 +27,13 @@ class DishResultSheet extends ConsumerStatefulWidget {
final String? preselectedMealType;
final String? jobId;
/// The diary date to add the entry to (YYYY-MM-DD).
/// Falls back to [createdAt] date, then today.
final String? targetDate;
/// Job creation timestamp used as fallback date when [targetDate] is null.
final DateTime? createdAt;
@override
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
}
@@ -32,6 +42,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
late int _selectedIndex;
late int _portionGrams;
late String _mealType;
late DateTime _selectedDiaryDate;
bool _saving = false;
final TextEditingController _portionController = TextEditingController();
@@ -43,9 +54,19 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
_portionGrams = widget.dish.candidates.isNotEmpty
? widget.dish.candidates.first.weightGrams
: 300;
_mealType = widget.preselectedMealType ??
kAllMealTypes.first.id;
_mealType = widget.preselectedMealType ?? kAllMealTypes.first.id;
_portionController.text = '$_portionGrams';
if (widget.targetDate != null) {
final parts = widget.targetDate!.split('-');
_selectedDiaryDate = DateTime(
int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2]),
);
} else if (widget.createdAt != null) {
final createdAtDate = widget.createdAt!;
_selectedDiaryDate = DateTime(createdAtDate.year, createdAtDate.month, createdAtDate.day);
} else {
_selectedDiaryDate = DateTime.now();
}
}
@override
@@ -79,6 +100,17 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
});
}
Future<void> _pickDate() async {
final now = DateTime.now();
final pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDiaryDate,
firstDate: now.subtract(const Duration(days: 365)),
lastDate: now,
);
if (pickedDate != null) setState(() => _selectedDiaryDate = pickedDate);
}
void _onPortionEdited(String value) {
final parsed = int.tryParse(value);
if (parsed != null && parsed >= 10) {
@@ -90,8 +122,8 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
if (_saving) return;
setState(() => _saving = true);
final selectedDate = ref.read(selectedDateProvider);
final dateString = formatDateForDiary(selectedDate);
final l10n = AppLocalizations.of(context)!;
final dateString = formatDateForDiary(_selectedDiaryDate);
final scaledCalories = _scale(_selected.calories);
final scaledProtein = _scale(_selected.proteinG);
@@ -120,7 +152,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
if (mounted) {
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось добавить. Попробуйте ещё раз.')),
SnackBar(content: Text(l10n.addFailed)),
);
}
}
@@ -128,6 +160,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final hasCandidates = widget.dish.candidates.isNotEmpty;
@@ -150,7 +183,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row(
children: [
Text('Распознано блюдо', style: theme.textTheme.titleMedium),
Text(l10n.dishResultTitle, style: theme.textTheme.titleMedium),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
@@ -179,7 +212,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
),
const SizedBox(height: 8),
Text(
'КБЖУ приблизительные — определены по фото.',
l10n.nutritionApproximate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -199,6 +232,11 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
if (value != null) setState(() => _mealType = value);
},
),
const SizedBox(height: 20),
_DatePickerRow(
date: _selectedDiaryDate,
onTap: _pickDate,
),
const SizedBox(height: 16),
],
)
@@ -207,13 +245,13 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Блюдо не распознано',
l10n.dishNotRecognized,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('Попробовать снова'),
child: Text(l10n.tryAgain),
),
],
),
@@ -232,7 +270,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Добавить в журнал'),
: Text(l10n.addToJournal),
),
),
),
@@ -241,6 +279,47 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
}
}
// ---------------------------------------------------------------------------
// Date picker row
// ---------------------------------------------------------------------------
class _DatePickerRow extends StatelessWidget {
final DateTime date;
final VoidCallback onTap;
const _DatePickerRow({required this.date, required this.onTap});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final target = DateTime(date.year, date.month, date.day);
final String label;
if (target == today) {
label = l10n.today;
} else if (target == today.subtract(const Duration(days: 1))) {
label = l10n.yesterday;
} else {
label = '${date.day.toString().padLeft(2, '0')}.${date.month.toString().padLeft(2, '0')}.${date.year}';
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: InputDecorator(
decoration: InputDecoration(
labelText: l10n.dateLabel,
border: const OutlineInputBorder(),
suffixIcon: const Icon(Icons.calendar_today_outlined),
),
child: Text(label),
),
);
}
}
// ---------------------------------------------------------------------------
// Candidates selector
// ---------------------------------------------------------------------------
@@ -258,11 +337,12 @@ class _CandidatesSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Выберите блюдо', style: theme.textTheme.titleMedium),
Text(l10n.selectDish, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...candidates.asMap().entries.map((entry) {
final index = entry.key;
@@ -378,6 +458,7 @@ class _NutritionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Card(
child: Padding(
@@ -388,7 +469,7 @@ class _NutritionCard extends StatelessWidget {
Row(
children: [
Text(
'${calories.toInt()} ккал',
'${calories.toInt()} ${l10n.caloriesUnit}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
@@ -396,7 +477,7 @@ class _NutritionCard extends StatelessWidget {
),
const Spacer(),
Tooltip(
message: 'Приблизительные значения на основе фото',
message: l10n.nutritionApproximate,
child: Text(
'',
style: theme.textTheme.titleLarge?.copyWith(
@@ -411,18 +492,18 @@ class _NutritionCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_MacroChip(
label: 'Белки',
value: '${proteinG.toStringAsFixed(1)} г',
label: l10n.proteinLabel,
value: '${proteinG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.blue,
),
_MacroChip(
label: 'Жиры',
value: '${fatG.toStringAsFixed(1)} г',
label: l10n.fatLabel,
value: '${fatG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.orange,
),
_MacroChip(
label: 'Углеводы',
value: '${carbsG.toStringAsFixed(1)} г',
label: l10n.carbsLabel,
value: '${carbsG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.green,
),
],
@@ -482,11 +563,12 @@ class _PortionRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Порция', style: theme.textTheme.titleSmall),
Text(l10n.portion, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
Row(
children: [
@@ -502,9 +584,9 @@ class _PortionRow extends StatelessWidget {
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: onChanged,
decoration: const InputDecoration(
suffixText: 'г',
border: OutlineInputBorder(),
decoration: InputDecoration(
suffixText: l10n.gramsUnit,
border: const OutlineInputBorder(),
),
),
),
@@ -535,11 +617,12 @@ class _MealTypeDropdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Приём пищи', style: theme.textTheme.titleSmall),
Text(l10n.mealType, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: selected,
@@ -550,7 +633,7 @@ class _MealTypeDropdown extends StatelessWidget {
.map((mealTypeOption) => DropdownMenuItem(
value: mealTypeOption.id,
child: Text(
'${mealTypeOption.emoji} ${mealTypeOption.label}'),
'${mealTypeOption.emoji} ${mealTypeLabel(mealTypeOption.id, l10n)}'),
))
.toList(),
onChanged: onChanged,

View File

@@ -1,21 +1,23 @@
import 'package:flutter/material.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../home/home_provider.dart';
import '../scan/dish_result_screen.dart';
import 'recognition_service.dart';
/// Full-screen page showing all of today's unlinked dish recognition jobs.
/// Full-screen page showing all dish recognition jobs.
class RecognitionHistoryScreen extends ConsumerWidget {
const RecognitionHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobsState = ref.watch(todayJobsProvider);
final l10n = AppLocalizations.of(context)!;
final jobsState = ref.watch(allJobsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('История распознавания'),
title: Text(l10n.historyTitle),
),
body: jobsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -23,20 +25,18 @@ class RecognitionHistoryScreen extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Не удалось загрузить историю'),
Text(l10n.historyLoadError),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(todayJobsProvider),
child: const Text('Повторить'),
onPressed: () => ref.invalidate(allJobsProvider),
child: Text(l10n.retry),
),
],
),
),
data: (jobs) {
if (jobs.isEmpty) {
return const Center(
child: Text('Нет распознаваний за сегодня'),
);
return Center(child: Text(l10n.noHistory));
}
return ListView.separated(
padding: const EdgeInsets.all(16),
@@ -58,6 +58,7 @@ class _HistoryJobTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDone = job.status == 'done';
@@ -84,11 +85,11 @@ class _HistoryJobTile extends ConsumerWidget {
final String titleText;
if (isDone) {
titleText = dishName ?? 'Блюдо распознано';
titleText = dishName ?? l10n.dishRecognized;
} else if (isProcessing) {
titleText = 'Распознаётся…';
titleText = l10n.recognizing;
} else {
titleText = 'Ошибка распознавания';
titleText = l10n.recognitionError;
}
final contextParts = [
@@ -118,6 +119,8 @@ class _HistoryJobTile extends ConsumerWidget {
dish: job.result!,
preselectedMealType: job.targetMealType,
jobId: job.id,
targetDate: job.targetDate,
createdAt: job.createdAt,
onAdded: () => Navigator.pop(sheetContext),
),
);

View File

@@ -282,7 +282,16 @@ class RecognitionService {
/// Returns today's recognition jobs that have not yet been linked to a diary entry.
Future<List<DishJobSummary>> listTodayUnlinkedJobs() async {
final data = await _client.get('/ai/jobs') as List<dynamic>;
final data = await _client.getList('/ai/jobs');
return data
.map((element) =>
DishJobSummary.fromJson(element as Map<String, dynamic>))
.toList();
}
/// Returns all recognition jobs for the current user, newest first.
Future<List<DishJobSummary>> listAllJobs() async {
final data = await _client.getList('/ai/jobs/history');
return data
.map((element) =>
DishJobSummary.fromJson(element as Map<String, dynamic>))