Files
food-ai/client/lib/features/home/home_screen.dart
dbastrikin 180c741424 feat: dish recognition UX, background mode, and backend bug fixes
Flutter client:
- Progress dialog: redesigned with pulsing animated icon, info hint about
  background mode, full-width Minimize button; dismiss signal via ValueNotifier
  so the dialog always closes regardless of widget lifecycle
- Background recognition: when user taps Minimize, wasMinimizedByUser flag is
  set; on completion a snackbar is shown instead of opening DishResultSheet
  directly; snackbar action opens the sheet on demand
- Fix dialog spinning forever: finally block guarantees dismissSignal=true on
  all exit paths including early returns from context.mounted checks
- Fix DishResultSheet not appearing: add ValueKey to _DailyMealsSection and
  meal card Padding so Flutter reuses elements when _TodayJobsWidget is
  inserted/removed from the SliverChildListDelegate list
- todayJobsProvider refresh: added refresh() method; called after job submit
  and on DishJobDone; all ref.read() calls guarded with context.mounted checks
- food_search_sheet: scan buttons replaced with full-width stacked OutlinedButtons
- app.dart: WidgetsBindingObserver refreshes scan providers on app resume
- L10n: added dishRecognitionHint and minimize keys to all 12 locales

Backend:
- migrations/003: ALTER TYPE recipe_source ADD VALUE 'recommendation' to fix
  22P02 error in GET /home/summary -> getRecommendations()
- item_enricher: normalizeProductCategory() validates AI-returned category
  against known slugs, falls back to "other" — fixes products_category_fkey
  FK violation during receipt recognition
- recognition prompt: enumerate valid categories so AI returns correct values

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

1935 lines
63 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(
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<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({
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<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.
// 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<bool>(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<bool> 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<double> _ringScale;
late final Animation<double> _ringOpacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat(reverse: true);
_ringScale = Tween<double>(begin: 0.88, end: 1.12).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_ringOpacity = Tween<double>(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<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),
);
}