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

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