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( 0.0, (sum, entry) => sum + (entry.calories ?? 0)); final loggedProtein = entries.fold( 0.0, (sum, entry) => sum + (entry.proteinG ?? 0)); final loggedFat = entries.fold( 0.0, (sum, entry) => sum + (entry.fatG ?? 0)); final loggedCarbs = entries.fold( 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( key: const ValueKey('daily_meals_section'), 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 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 mealTypeIds; final List entries; final String dateString; const _DailyMealsSection({ super.key, 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( key: ValueKey(mealTypeId), padding: const EdgeInsets.only(bottom: 8), child: _MealCard( mealTypeOption: mealTypeOption, entries: mealEntries, dateString: dateString, plannedSlots: mealPlannedSlots, ), ); }), ], ); } } Future _pickAndShowDishResult( BuildContext context, WidgetRef ref, String mealTypeId, ) async { final l10n = AppLocalizations.of(context)!; // 1. Choose image source. final source = await showModalBottomSheet( 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. // Use a dismiss signal so the dialog can close itself from within its own // context, avoiding GoRouter's inner-navigator pop() issues. final progressNotifier = _DishProgressNotifier(initialMessage: l10n.analyzingPhoto); final dismissSignal = ValueNotifier(false); bool wasMinimizedByUser = false; showDialog( context: context, barrierDismissible: false, builder: (_) => _DishProgressDialog( notifier: progressNotifier, dismissSignal: dismissSignal, onMinimize: () { wasMinimizedByUser = true; }, ), ); // 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, ); // Refresh immediately so the new queued job appears in the home screen list. if (!context.mounted) return; ref.read(todayJobsProvider.notifier).refresh(); 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(): dismissSignal.value = true; if (!context.mounted) return; ref.read(todayJobsProvider.notifier).refresh(); if (wasMinimizedByUser) { // Recognition finished in background — notify without opening the sheet. ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.dishRecognized), action: SnackBarAction( label: l10n.addToJournal, onPressed: () { 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), ), ); }, ), ), ); } else { 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(): dismissSignal.value = true; 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) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.recognitionFailed)), ); } } finally { // Guarantee the dialog is always dismissed — covers early returns due to // context.mounted being false, stream ending without a terminal event, or // any unhandled exception. ValueNotifier deduplicates so double-setting is safe. dismissSignal.value = true; } } // --------------------------------------------------------------------------- // 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 StatefulWidget { final _DishProgressNotifier notifier; final ValueNotifier dismissSignal; /// Called when the user explicitly closes the dialog via the Minimize button /// (i.e., before recognition has finished). final VoidCallback? onMinimize; const _DishProgressDialog({ required this.notifier, required this.dismissSignal, this.onMinimize, }); @override State<_DishProgressDialog> createState() => _DishProgressDialogState(); } class _DishProgressDialogState extends State<_DishProgressDialog> { @override void initState() { super.initState(); widget.dismissSignal.addListener(_onDismissSignal); } @override void dispose() { widget.dismissSignal.removeListener(_onDismissSignal); super.dispose(); } void _onDismissSignal() { if (widget.dismissSignal.value && mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) Navigator.of(context, rootNavigator: true).pop(); }); } } void _minimize() { widget.onMinimize?.call(); Navigator.of(context, rootNavigator: true).pop(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), child: Padding( padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), child: ListenableBuilder( listenable: widget.notifier, builder: (_, __) { final state = widget.notifier.state; return Column( mainAxisSize: MainAxisSize.min, children: [ const _PulsingRecognitionIcon(), const SizedBox(height: 24), Text( state.message, style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), if (state.showUpgrade) ...[ const SizedBox(height: 8), Text( l10n.upgradePrompt, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.primary, ), textAlign: TextAlign.center, ), ], const SizedBox(height: 24), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.onSurface.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(12), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.info_outline_rounded, size: 16, color: colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), Expanded( child: Text( l10n.dishRecognitionHint, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ], ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: OutlinedButton( onPressed: _minimize, child: Text(l10n.minimize), ), ), ], ); }, ), ), ); } } // Pulsing animated icon shown while dish recognition is in progress. class _PulsingRecognitionIcon extends StatefulWidget { const _PulsingRecognitionIcon(); @override State<_PulsingRecognitionIcon> createState() => _PulsingRecognitionIconState(); } class _PulsingRecognitionIconState extends State<_PulsingRecognitionIcon> with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _ringScale; late final Animation _ringOpacity; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(reverse: true); _ringScale = Tween(begin: 0.88, end: 1.12).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); _ringOpacity = Tween(begin: 0.12, end: 0.45).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return SizedBox( width: 100, height: 100, child: AnimatedBuilder( animation: _controller, builder: (_, __) => Stack( alignment: Alignment.center, children: [ Transform.scale( scale: _ringScale.value, child: Container( width: 100, height: 100, decoration: BoxDecoration( shape: BoxShape.circle, color: colorScheme.primary .withValues(alpha: _ringOpacity.value), ), ), ), Container( width: 64, height: 64, decoration: BoxDecoration( shape: BoxShape.circle, color: colorScheme.primaryContainer, ), child: Icon( Icons.restaurant_outlined, size: 30, color: colorScheme.onPrimaryContainer, ), ), ], ), ), ); } } class _MealCard extends ConsumerWidget { final MealTypeOption mealTypeOption; final List entries; final String dateString; final List 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( 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().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( 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 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 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( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => PlanProductsSheet( onContinue: (selectedProductIds) { // Step 2: planning mode selection showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (_) => PlanMenuSheet( selectedProductIds: selectedProductIds, onModeSelected: (mode, productIds) { // Step 3: date / meal type selection showModalBottomSheet( 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 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), ); }