Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin 9306d59d36 feat: unified food calendar — extend home screen to future dates + planned meals
Phase 1: date strip now covers today + 7 future days; right chevron enabled;
future pills rendered in lighter style.

Phase 2: home screen shows DateContext (past/today/future):
- future dates: hide calorie ring + macros, show PlanningBanner
- plannedMealsProvider derives from cached menuProvider (no extra API call)
- _MealCard shows ghost PlannedSlotTile for unconfirmed menu slots
- "Mark as eaten" creates a diary entry (source: menu_plan) via existing API

New l10n keys (12 locales): planningForDate, markAsEaten, plannedMealLabel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:56:17 +02:00

1578 lines
52 KiB
Dart
Raw 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/models/meal_type.dart';
import '../../shared/models/menu.dart';
import '../diary/food_search_sheet.dart';
import '../menu/menu_provider.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)
_PlanningBanner(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 7 future days + today + 364 past days = 372 items total.
// With reverse: true, index 0 is rendered at the RIGHT edge (newest).
// index 0 = today + 7, index 7 = today, index 371 = today - 364.
static const _futureDays = 7;
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);
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(
_offsetForIndex(_futureDays),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@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(
_offsetForIndex(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,
),
],
),
);
}
}
// ── 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),
],
),
),
),
);
}
}
// ── 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),
);
}