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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>))
|
||||
|
||||
Reference in New Issue
Block a user