Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin b38190ff5b feat: add product selection step before meal planning
Inserts a new PlanProductsSheet as step 1 of the planning flow.
Users see their current products as a multi-select checklist (all
selected by default) before choosing the planning mode and dates.

- Empty state explains the benefit and offers "Add products" CTA
  while always allowing "Plan without products" to skip
- Selected product IDs flow through PlanMenuSheet →
  PlanDatePickerSheet → MenuService.generateForDates → backend
- Backend: added ProductIDs field to generate-menu request body;
  uses ListForPromptByIDs when set, ListForPrompt otherwise
- Backend: added Repository.ListForPromptByIDs (filtered SQL query)
- All 12 ARB locale files updated with planProducts* keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:07:28 +02:00

1727 lines
57 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/home_summary.dart';
import '../../shared/constants/date_limits.dart';
import '../../shared/models/meal_type.dart';
import '../../shared/models/menu.dart';
import '../diary/food_search_sheet.dart';
import '../menu/menu_provider.dart';
import '../menu/plan_date_picker_sheet.dart';
import '../menu/plan_menu_sheet.dart';
import '../menu/plan_products_sheet.dart';
import '../profile/profile_provider.dart';
import '../scan/dish_result_screen.dart';
import '../scan/recognition_service.dart';
import 'home_provider.dart';
// ── Date context ───────────────────────────────────────────────
enum _DateContext { past, today, future }
_DateContext _contextFor(DateTime selected) {
final now = DateTime.now();
final todayNormalized = DateTime(now.year, now.month, now.day);
final selectedNormalized =
DateTime(selected.year, selected.month, selected.day);
if (selectedNormalized.isBefore(todayNormalized)) return _DateContext.past;
if (selectedNormalized.isAtSameMomentAs(todayNormalized)) {
return _DateContext.today;
}
return _DateContext.future;
}
// ── Root screen ───────────────────────────────────────────────
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@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;
final goalType = profile?.goal;
final dailyGoal = profile?.dailyCalories ?? 0;
final userMealTypes = profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner'];
final selectedDate = ref.watch(selectedDateProvider);
final dateString = formatDateForDiary(selectedDate);
final dateContext = _contextFor(selectedDate);
final isFutureDate = dateContext == _DateContext.future;
final diaryState = ref.watch(diaryProvider(dateString));
final entries = diaryState.valueOrNull ?? [];
final loggedCalories = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.calories ?? 0));
final loggedProtein = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.proteinG ?? 0));
final loggedFat = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.fatG ?? 0));
final loggedCarbs = entries.fold<double>(
0.0, (sum, entry) => sum + (entry.carbsG ?? 0));
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
final todayJobs = ref.watch(todayJobsProvider).valueOrNull ?? [];
return Scaffold(
body: RefreshIndicator(
onRefresh: () async {
ref.read(homeProvider.notifier).load();
ref.invalidate(diaryProvider(dateString));
ref.invalidate(todayJobsProvider);
},
child: CustomScrollView(
slivers: [
_AppBar(userName: userName),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 100),
sliver: SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 12),
_DateSelector(
selectedDate: selectedDate,
onDateSelected: (date) =>
ref.read(selectedDateProvider.notifier).state = date,
),
const SizedBox(height: 16),
if (isFutureDate)
_FutureDayHeader(dateString: dateString)
else ...[
_CaloriesCard(
loggedCalories: loggedCalories,
dailyGoal: dailyGoal,
goalType: goalType,
),
const SizedBox(height: 12),
_MacrosRow(
proteinG: loggedProtein,
fatG: loggedFat,
carbsG: loggedCarbs,
),
],
if (!isFutureDate && todayJobs.isNotEmpty) ...[
const SizedBox(height: 16),
_TodayJobsWidget(jobs: todayJobs),
],
const SizedBox(height: 16),
_DailyMealsSection(
mealTypeIds: userMealTypes,
entries: entries,
dateString: dateString,
),
if (!isFutureDate && expiringSoon.isNotEmpty) ...[
const SizedBox(height: 16),
_ExpiringBanner(items: expiringSoon),
],
const SizedBox(height: 16),
_QuickActionsRow(),
if (!isFutureDate && recommendations.isNotEmpty) ...[
const SizedBox(height: 20),
_SectionTitle(l10n.recommendCook),
const SizedBox(height: 12),
_RecommendationsRow(recipes: recommendations),
],
]),
),
),
],
),
),
);
}
}
// ── App bar ───────────────────────────────────────────────────
class _AppBar extends StatelessWidget {
final String? userName;
const _AppBar({this.userName});
String _greetingBase(AppLocalizations l10n) {
final hour = DateTime.now().hour;
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),
);
}
}
// ── Date selector ─────────────────────────────────────────────
class _DateSelector extends StatefulWidget {
final DateTime selectedDate;
final ValueChanged<DateTime> onDateSelected;
const _DateSelector({
required this.selectedDate,
required this.onDateSelected,
});
@override
State<_DateSelector> createState() => _DateSelectorState();
}
class _DateSelectorState extends State<_DateSelector> {
// Strip covers kPlanningHorizonDays future days + today + 364 past days.
// With reverse: true, index 0 is rendered at the RIGHT edge (newest).
// index 0 = today + kPlanningHorizonDays, index kPlanningHorizonDays = today.
static const _futureDays = kPlanningHorizonDays;
static const _pastDays = 364;
static const _totalDays = _futureDays + 1 + _pastDays; // 372
static const _pillWidth = 48.0;
static const _pillSpacing = 6.0;
late final ScrollController _scrollController;
String _formatSelectedDate(DateTime date, String localeCode) {
final now = DateTime.now();
final yearSuffix = date.year != now.year ? ' ${date.year}' : '';
return DateFormat('EEE, d MMMM', localeCode).format(date) + yearSuffix;
}
// Maps a date to its index in the reversed ListView.
// today → _futureDays, tomorrow → _futureDays - 1, … , +7 → 0.
// yesterday → _futureDays + 1, … , -364 → _futureDays + 364.
int _indexForDate(DateTime date) {
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
final dateNormalized = DateTime(date.year, date.month, date.day);
final daysFromToday =
dateNormalized.difference(todayNormalized).inDays;
return (_futureDays - daysFromToday).clamp(0, _totalDays - 1);
}
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
double _centeredOffset(int index) {
final rawOffset = _offsetForIndex(index);
if (!_scrollController.hasClients) return rawOffset;
final viewportWidth = _scrollController.position.viewportDimension;
final centeredOffset = rawOffset - viewportWidth / 2 + _pillWidth / 2;
return centeredOffset < 0 ? 0 : centeredOffset;
}
void _selectPreviousDay() {
final previousDay = widget.selectedDate.subtract(const Duration(days: 1));
final previousDayNormalized =
DateTime(previousDay.year, previousDay.month, previousDay.day);
final today = DateTime.now();
final oldestAllowed = DateTime(today.year, today.month, today.day)
.subtract(const Duration(days: _pastDays));
if (!previousDayNormalized.isBefore(oldestAllowed)) {
widget.onDateSelected(previousDayNormalized);
}
}
void _selectNextDay() {
final today = DateTime.now();
final futureLimitDate = DateTime(today.year, today.month, today.day)
.add(const Duration(days: _futureDays));
final nextDay = widget.selectedDate.add(const Duration(days: 1));
final nextDayNormalized =
DateTime(nextDay.year, nextDay.month, nextDay.day);
if (!nextDayNormalized.isAfter(futureLimitDate)) {
widget.onDateSelected(nextDayNormalized);
}
}
void _jumpToToday() {
final today = DateTime.now();
widget.onDateSelected(DateTime(today.year, today.month, today.day));
if (_scrollController.hasClients) {
_scrollController.animateTo(
_centeredOffset(_futureDays),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
void initState() {
super.initState();
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final initialIndex = _indexForDate(widget.selectedDate);
_scrollController.jumpTo(_centeredOffset(initialIndex));
});
}
@override
void didUpdateWidget(_DateSelector oldWidget) {
super.didUpdateWidget(oldWidget);
final oldIndex = _indexForDate(oldWidget.selectedDate);
final newIndex = _indexForDate(widget.selectedDate);
if (oldIndex != newIndex && _scrollController.hasClients) {
_scrollController.animateTo(
_centeredOffset(newIndex),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@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);
final selectedNormalized = DateTime(
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
final isToday = selectedNormalized == todayNormalized;
final futureLimitDate =
todayNormalized.add(const Duration(days: _futureDays));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Header row ──────────────────────────────────────────
SizedBox(
height: 36,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: _selectPreviousDay,
),
Expanded(
child: Text(
_formatSelectedDate(widget.selectedDate, localeCode),
style: theme.textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
if (!isToday)
TextButton(
onPressed: _jumpToToday,
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
l10n.today,
style: theme.textTheme.labelMedium
?.copyWith(color: theme.colorScheme.primary),
),
)
else
IconButton(
icon: const Icon(Icons.chevron_right),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: selectedNormalized.isBefore(futureLimitDate)
? _selectNextDay
: null,
),
],
),
),
const SizedBox(height: 8),
// ── Day strip ────────────────────────────────────────────
// reverse: true → index 0 (7 days from now) at the right edge;
// index _futureDays = today; past dates have higher indices.
SizedBox(
height: 56,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
reverse: true,
itemCount: _totalDays,
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
itemBuilder: (listContext, index) {
final date = todayNormalized
.add(Duration(days: _futureDays - index));
final isSelected = date == selectedNormalized;
final isDayToday = date == todayNormalized;
final isDayFuture = date.isAfter(todayNormalized);
return GestureDetector(
onTap: () => widget.onDateSelected(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: _pillWidth,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: isDayFuture
? theme.colorScheme.surfaceContainerLow
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isDayFuture && !isSelected
? Border.all(
color: theme.colorScheme.outline
.withValues(alpha: 0.3),
width: 1,
)
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
DateFormat('EEE', localeCode).format(date),
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: isDayFuture
? theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'${date.day}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSelected || isDayToday
? FontWeight.w700
: FontWeight.normal,
color: isSelected
? theme.colorScheme.onPrimary
: isDayToday
? theme.colorScheme.primary
: isDayFuture
? theme.colorScheme.onSurface
.withValues(alpha: 0.5)
: null,
),
),
],
),
),
);
},
),
),
],
);
}
}
// ── Calories card ─────────────────────────────────────────────
class _CaloriesCard extends StatelessWidget {
final double loggedCalories;
final int dailyGoal;
final String? goalType;
const _CaloriesCard({
required this.loggedCalories,
required this.dailyGoal,
required this.goalType,
});
@override
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 secondaryValue;
final Color secondaryColor;
if (isOverGoal) {
final overBy = (loggedCalories - dailyGoal).toInt();
secondaryValue = '+$overBy ${l10n.caloriesUnit}';
secondaryColor = AppColors.error;
} else {
final remaining = (dailyGoal - loggedCalories).toInt();
secondaryValue = '$remaining ${l10n.caloriesUnit}';
secondaryColor = AppColors.textSecondary;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
SizedBox(
width: 160,
height: 160,
child: Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: const Size(160, 160),
painter: _CalorieRingPainter(
rawProgress: rawProgress,
ringColor: ringColor,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$logged',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: ringColor,
),
),
Text(
l10n.caloriesUnit,
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
'${l10n.goalLabel} $dailyGoal',
style: theme.textTheme.labelSmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
],
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CalorieStat(
label: l10n.consumed,
value: '$logged ${l10n.caloriesUnit}',
valueColor: ringColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: isOverGoal ? l10n.exceeded : l10n.remaining,
value: secondaryValue,
valueColor: secondaryColor,
),
const SizedBox(height: 12),
_CalorieStat(
label: l10n.goalLabel.replaceAll(':', '').trim(),
value: '$dailyGoal ${l10n.caloriesUnit}',
valueColor: AppColors.textPrimary,
),
],
),
),
],
),
),
);
}
}
class _CalorieStat extends StatelessWidget {
final String label;
final String value;
final Color valueColor;
const _CalorieStat({
required this.label,
required this.value,
required this.valueColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style:
theme.textTheme.labelSmall?.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 2),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
// ── Macros row ────────────────────────────────────────────────
class _MacrosRow extends StatelessWidget {
final double proteinG;
final double fatG;
final double carbsG;
const _MacrosRow({
required this.proteinG,
required this.fatG,
required this.carbsG,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: _MacroChip(
label: l10n.proteinLabel,
value: '${proteinG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.blue,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: l10n.fatLabel,
value: '${fatG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.orange,
),
),
const SizedBox(width: 8),
Expanded(
child: _MacroChip(
label: l10n.carbsLabel,
value: '${carbsG.toStringAsFixed(1)} ${l10n.gramsUnit}',
color: Colors.green,
),
),
],
);
}
}
class _MacroChip extends StatelessWidget {
final String label;
final String value;
final Color color;
const _MacroChip({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
// ── Ring colour logic ──────────────────────────────────────────
Color _ringColorFor(double rawProgress, String? goalType) {
switch (goalType) {
case 'lose':
if (rawProgress >= 1.10) return AppColors.error;
if (rawProgress >= 0.95) return AppColors.warning;
if (rawProgress >= 0.75) return AppColors.success;
return AppColors.primary;
case 'maintain':
if (rawProgress >= 1.25) return AppColors.error;
if (rawProgress >= 1.11) return AppColors.warning;
if (rawProgress >= 0.90) return AppColors.success;
if (rawProgress >= 0.70) return AppColors.warning;
return AppColors.primary;
case 'gain':
if (rawProgress < 0.60) return AppColors.error;
if (rawProgress < 0.85) return AppColors.warning;
if (rawProgress <= 1.15) return AppColors.success;
return AppColors.primary;
default:
return AppColors.primary;
}
}
// ── Ring painter ───────────────────────────────────────────────
class _CalorieRingPainter extends CustomPainter {
final double rawProgress;
final Color ringColor;
const _CalorieRingPainter({
required this.rawProgress,
required this.ringColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - 20) / 2;
const strokeWidth = 10.0;
const overflowStrokeWidth = 6.0;
const startAngle = -math.pi / 2;
final trackPaint = Paint()
..color = AppColors.separator.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, trackPaint);
final clampedProgress = rawProgress.clamp(0.0, 1.0);
if (clampedProgress > 0) {
final arcPaint = Paint()
..color = ringColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
clampedProgress * 2 * math.pi,
false,
arcPaint,
);
}
if (rawProgress > 1.0) {
final overflowProgress = rawProgress - 1.0;
final overflowPaint = Paint()
..color = ringColor.withValues(alpha: 0.70)
..style = PaintingStyle.stroke
..strokeWidth = overflowStrokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle,
overflowProgress.clamp(0.0, 1.0) * 2 * math.pi,
false,
overflowPaint,
);
}
}
@override
bool shouldRepaint(_CalorieRingPainter oldDelegate) =>
oldDelegate.rawProgress != rawProgress ||
oldDelegate.ringColor != ringColor;
}
// ── Daily meals section ───────────────────────────────────────
class _DailyMealsSection extends ConsumerWidget {
final List<String> mealTypeIds;
final List<DiaryEntry> entries;
final String dateString;
const _DailyMealsSection({
required this.mealTypeIds,
required this.entries,
required this.dateString,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final plannedSlots = ref.watch(plannedMealsProvider(dateString));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.mealsSection, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
...mealTypeIds.map((mealTypeId) {
final mealTypeOption = mealTypeById(mealTypeId);
if (mealTypeOption == null) return const SizedBox.shrink();
final mealEntries = entries
.where((entry) => entry.mealType == mealTypeId)
.toList();
final mealPlannedSlots = plannedSlots
.where((slot) => slot.mealType == mealTypeId)
.toList();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MealCard(
mealTypeOption: mealTypeOption,
entries: mealEntries,
dateString: dateString,
plannedSlots: mealPlannedSlots,
),
);
}),
],
);
}
}
Future<void> _pickAndShowDishResult(
BuildContext context,
WidgetRef ref,
String mealTypeId,
) async {
final l10n = AppLocalizations.of(context)!;
// 1. Choose image source.
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text(l10n.camera),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library),
title: Text(l10n.gallery),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null || !context.mounted) return;
// 2. Pick image.
final image = await ImagePicker().pickImage(
source: source,
imageQuality: 70,
maxWidth: 1024,
maxHeight: 1024,
);
if (image == null || !context.mounted) return;
// 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(initialMessage: l10n.analyzingPhoto);
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
);
// 4. Determine target date and meal type for context.
final selectedDate = ref.read(selectedDateProvider);
final targetDate = formatDateForDiary(selectedDate);
final localPreferences = ref.read(localPreferencesProvider);
final resolvedMealType = mealTypeId.isNotEmpty
? mealTypeId
: localPreferences.getLastUsedMealType();
// 5. Submit image and listen to SSE stream.
final service = ref.read(recognitionServiceProvider);
try {
final jobCreated = await service.submitDishRecognition(
image,
targetDate: targetDate,
targetMealType: resolvedMealType,
);
if (!context.mounted) return;
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
if (!context.mounted) break;
switch (event) {
case DishJobQueued():
progressNotifier.update(
message: '${l10n.inQueue} · ${l10n.queuePosition(event.position + 1)}',
showUpgrade: event.position > 0,
);
case DishJobProcessing():
progressNotifier.update(message: l10n.processing);
case DishJobDone():
rootNavigator.pop(); // close dialog
if (!context.mounted) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: event.result,
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
jobId: jobCreated.jobId,
targetDate: targetDate,
onAdded: () => Navigator.pop(sheetContext),
),
);
return;
case DishJobFailed():
rootNavigator.pop(); // close dialog
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(event.error),
action: SnackBarAction(
label: l10n.retry,
onPressed: () => _pickAndShowDishResult(context, ref, mealTypeId),
),
),
);
return;
}
}
} catch (recognitionError) {
debugPrint('Dish recognition error: $recognitionError');
if (context.mounted) {
rootNavigator.pop(); // close dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recognitionFailed)),
);
}
}
}
// ---------------------------------------------------------------------------
// Async recognition progress dialog
// ---------------------------------------------------------------------------
class _DishProgressState {
final String message;
final bool showUpgrade;
const _DishProgressState({
required this.message,
this.showUpgrade = false,
});
}
class _DishProgressNotifier extends ChangeNotifier {
late _DishProgressState _state;
_DishProgressNotifier({required String initialMessage})
: _state = _DishProgressState(message: initialMessage);
_DishProgressState get state => _state;
void update({required String message, bool showUpgrade = false}) {
_state = _DishProgressState(message: message, showUpgrade: showUpgrade);
notifyListeners();
}
}
class _DishProgressDialog extends StatelessWidget {
final _DishProgressNotifier notifier;
const _DishProgressDialog({required this.notifier});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListenableBuilder(
listenable: notifier,
builder: (context, _) {
final state = notifier.state;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(state.message, textAlign: TextAlign.center),
if (state.showUpgrade) ...[
const SizedBox(height: 12),
Text(
l10n.upgradePrompt,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
],
],
),
);
},
);
}
}
class _MealCard extends ConsumerWidget {
final MealTypeOption mealTypeOption;
final List<DiaryEntry> entries;
final String dateString;
final List<MealSlot> plannedSlots;
const _MealCard({
required this.mealTypeOption,
required this.entries,
required this.dateString,
this.plannedSlots = const [],
});
@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));
// Recipe IDs that are already confirmed in the diary — don't show ghost.
final confirmedRecipeIds =
entries.map((entry) => entry.recipeId).whereType<String>().toSet();
final unconfirmedSlots = plannedSlots
.where((slot) =>
slot.recipe != null &&
!confirmedRecipeIds.contains(slot.recipe!.id))
.toList();
return Card(
child: Column(
children: [
// Header row
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
child: Row(
children: [
Text(mealTypeOption.emoji,
style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(mealTypeLabel(mealTypeOption.id, l10n),
style: theme.textTheme.titleSmall),
const Spacer(),
if (totalCalories > 0)
Text(
'${totalCalories.toInt()} ${l10n.caloriesUnit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
visualDensity: VisualDensity.compact,
tooltip: l10n.addDish,
onPressed: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => FoodSearchSheet(
mealType: mealTypeOption.id,
date: dateString,
onAdded: () => ref
.invalidate(diaryProvider(dateString)),
onScanDish: () => _pickAndShowDishResult(
context, ref, mealTypeOption.id),
),
);
},
),
],
),
),
// Planned (ghost) slots from the menu
if (unconfirmedSlots.isNotEmpty) ...[
const Divider(height: 1, indent: 16),
...unconfirmedSlots.map((slot) => _PlannedSlotTile(
slot: slot,
onConfirm: () =>
ref.read(diaryProvider(dateString).notifier).add({
'date': dateString,
'meal_type': mealTypeOption.id,
'recipe_id': slot.recipe!.id,
'name': slot.recipe!.title,
'portions': 1,
'source': 'menu_plan',
}),
)),
],
// Confirmed diary entries
if (entries.isNotEmpty) ...[
const Divider(height: 1, indent: 16),
...entries.map((entry) => _DiaryEntryTile(
entry: entry,
onDelete: () => ref
.read(diaryProvider(dateString).notifier)
.remove(entry.id),
)),
],
],
),
);
}
}
class _DiaryEntryTile extends StatelessWidget {
final DiaryEntry entry;
final VoidCallback onDelete;
const _DiaryEntryTile({required this.entry, required this.onDelete});
@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;
final hasFat = (entry.fatG ?? 0) > 0;
final hasCarbs = (entry.carbsG ?? 0) > 0;
return ListTile(
dense: true,
title: Text(entry.name, style: theme.textTheme.bodyMedium),
subtitle: (hasProtein || hasFat || hasCarbs)
? Text(
[
if (hasProtein) 'Б ${entry.proteinG!.toStringAsFixed(1)}',
if (hasFat) 'Ж ${entry.fatG!.toStringAsFixed(1)}',
if (hasCarbs) 'У ${entry.carbsG!.toStringAsFixed(1)}',
].join(' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (calories != null)
Text(
'$calories ${l10n.caloriesUnit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: Icon(Icons.delete_outline,
size: 18, color: theme.colorScheme.error),
visualDensity: VisualDensity.compact,
onPressed: onDelete,
),
],
),
);
}
}
// ── Future day header (wraps banner + menu-gen CTA) ────────────
class _FutureDayHeader extends ConsumerWidget {
final String dateString;
const _FutureDayHeader({required this.dateString});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final plannedMeals = ref.watch(plannedMealsProvider(dateString));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PlanningBanner(dateString: dateString),
const SizedBox(height: 8),
_FutureDayPlanButton(dateString: dateString),
if (plannedMeals.isNotEmpty) ...[
const SizedBox(height: 8),
_DayPlannedChip(l10n: l10n),
],
],
);
}
}
class _DayPlannedChip extends StatelessWidget {
final AppLocalizations l10n;
const _DayPlannedChip({required this.l10n});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
color: theme.colorScheme.onSecondaryContainer, size: 18),
const SizedBox(width: 8),
Text(
l10n.dayPlannedLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ── Planning banner (future dates) ────────────────────────────
class _PlanningBanner extends StatelessWidget {
final String dateString;
const _PlanningBanner({required this.dateString});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final localeCode = Localizations.localeOf(context).toString();
String formattedDate;
try {
final date = DateTime.parse(dateString);
formattedDate = DateFormat('EEE, d MMMM', localeCode).format(date);
} catch (_) {
formattedDate = dateString;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today_outlined,
color: theme.colorScheme.onPrimaryContainer, size: 20),
const SizedBox(width: 10),
Text(
l10n.planningForDate(formattedDate),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ── Planned slot tile (ghost entry from menu) ──────────────────
class _PlannedSlotTile extends StatelessWidget {
final MealSlot slot;
final VoidCallback onConfirm;
const _PlannedSlotTile({required this.slot, required this.onConfirm});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final recipe = slot.recipe;
if (recipe == null) return const SizedBox.shrink();
final calories = recipe.nutrition?.calories.toInt();
return Opacity(
opacity: 0.75,
child: ListTile(
dense: true,
title: Text(recipe.title, style: theme.textTheme.bodyMedium),
subtitle: Text(
l10n.plannedMealLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary.withValues(alpha: 0.8),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (calories != null)
Text(
'$calories ${l10n.caloriesUnit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 4),
IconButton(
icon: Icon(Icons.check_circle_outline,
size: 20, color: theme.colorScheme.primary),
visualDensity: VisualDensity.compact,
tooltip: l10n.markAsEaten,
onPressed: onConfirm,
),
],
),
),
);
}
}
// ── Expiring banner ───────────────────────────────────────────
class _ExpiringBanner extends StatelessWidget {
final List<ExpiringSoon> items;
const _ExpiringBanner({required this.items});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.errorContainer;
final onColor = theme.colorScheme.onErrorContainer;
return GestureDetector(
onTap: () => context.push('/products'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: onColor, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Истекает срок годности',
style: theme.textTheme.labelMedium?.copyWith(
color: onColor, fontWeight: FontWeight.w600),
),
Text(
items
.take(3)
.map((expiringSoonItem) =>
'${expiringSoonItem.name}${expiringSoonItem.expiryLabel}')
.join(', '),
style:
theme.textTheme.bodySmall?.copyWith(color: onColor),
),
],
),
),
Icon(Icons.chevron_right, color: onColor),
],
),
),
);
}
}
// ── Today's recognition jobs widget ───────────────────────────
class _TodayJobsWidget extends ConsumerWidget {
final List<DishJobSummary> jobs;
const _TodayJobsWidget({required this.jobs});
@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;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(l10n.dishRecognition, style: theme.textTheme.titleSmall),
const Spacer(),
if (hasMore)
TextButton(
onPressed: () => context.push('/scan/history'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: Text(
l10n.all,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
),
),
],
),
const SizedBox(height: 8),
...visibleJobs.map((job) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _JobTile(job: job),
)),
],
);
}
}
class _JobTile extends ConsumerWidget {
final DishJobSummary job;
const _JobTile({required this.job});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isDone = job.status == 'done';
final isFailed = job.status == 'failed';
final isProcessing =
job.status == 'processing' || job.status == 'pending';
final IconData statusIcon;
final Color statusColor;
if (isDone) {
statusIcon = Icons.check_circle_outline;
statusColor = Colors.green;
} else if (isFailed) {
statusIcon = Icons.error_outline;
statusColor = theme.colorScheme.error;
} else {
statusIcon = Icons.hourglass_top_outlined;
statusColor = theme.colorScheme.primary;
}
final dishName = job.result?.candidates.isNotEmpty == true
? job.result!.best.dishName
: null;
final subtitle = dishName ?? (isFailed ? (job.error ?? l10n.recognitionError) : l10n.recognizing);
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(
dishName ?? (isProcessing ? l10n.recognizing : l10n.recognitionError),
style: theme.textTheme.bodyMedium,
),
subtitle: Text(
[
if (job.targetMealType != null) job.targetMealType,
if (job.targetDate != null) job.targetDate,
].join(' · ').isEmpty ? subtitle : [
if (job.targetMealType != null) job.targetMealType,
if (job.targetDate != null) job.targetDate,
].join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
onTap: isDone && job.result != null
? () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => DishResultSheet(
dish: job.result!,
preselectedMealType: job.targetMealType,
jobId: job.id,
targetDate: job.targetDate,
createdAt: job.createdAt,
onAdded: () => Navigator.pop(sheetContext),
),
);
}
: null,
),
);
}
}
// ── Quick actions ─────────────────────────────────────────────
class _QuickActionsRow extends StatelessWidget {
const _QuickActionsRow();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Row(
children: [
Expanded(
child: _ActionButton(
icon: Icons.document_scanner_outlined,
label: l10n.scanDish,
onTap: () => context.push('/scan'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.calendar_month_outlined,
label: l10n.menu,
onTap: () => context.push('/menu'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.history,
label: l10n.dishHistory,
onTap: () => context.push('/scan/history'),
),
),
],
);
}
}
class _ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionButton(
{required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(icon, size: 24),
const SizedBox(height: 6),
Text(label,
style: theme.textTheme.labelSmall,
textAlign: TextAlign.center),
],
),
),
),
);
}
}
// ── Plan menu button ──────────────────────────────────────────
class _FutureDayPlanButton extends ConsumerWidget {
final String dateString;
const _FutureDayPlanButton({required this.dateString});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Card(
child: InkWell(
onTap: () => _openPlanSheet(context, ref),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(Icons.edit_calendar_outlined,
color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.planMenuButton,
style: theme.textTheme.titleSmall,
),
),
Icon(Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant),
],
),
),
),
);
}
void _openPlanSheet(BuildContext context, WidgetRef ref) {
final defaultStart = DateTime.parse(dateString);
// Step 1: product selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanProductsSheet(
onContinue: (selectedProductIds) {
// Step 2: planning mode selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanMenuSheet(
selectedProductIds: selectedProductIds,
onModeSelected: (mode, productIds) {
// Step 3: date / meal type selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanDatePickerSheet(
mode: mode,
defaultStart: defaultStart,
selectedProductIds: productIds,
),
);
},
),
);
},
),
);
}
}
// ── Section title ─────────────────────────────────────────────
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) =>
Text(text, style: Theme.of(context).textTheme.titleSmall);
}
// ── Recommendations row ───────────────────────────────────────
class _RecommendationsRow extends StatelessWidget {
final List<HomeRecipe> recipes;
const _RecommendationsRow({required this.recipes});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 168,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recipes.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
_RecipeCard(recipe: recipes[index]),
),
);
}
}
class _RecipeCard extends StatelessWidget {
final HomeRecipe recipe;
const _RecipeCard({required this.recipe});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return SizedBox(
width: 140,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
recipe.imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: recipe.imageUrl,
height: 96,
width: double.infinity,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => _imagePlaceholder(),
)
: _imagePlaceholder(),
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Text(
recipe.title,
style: theme.textTheme.bodySmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (recipe.calories != null)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text(
'${recipe.calories!.toInt()} ${l10n.caloriesUnit}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
),
],
),
),
),
);
}
Widget _imagePlaceholder() => Container(
height: 96,
color: Colors.grey.shade200,
child: const Icon(Icons.restaurant, color: Colors.grey),
);
}