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:
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '100–250';
|
||||
}
|
||||
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 '30–300';
|
||||
}
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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