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