feat: unified food calendar — extend home screen to future dates + planned meals

Phase 1: date strip now covers today + 7 future days; right chevron enabled;
future pills rendered in lighter style.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 22:56:17 +02:00
parent bf8dce36c5
commit 9306d59d36
28 changed files with 500 additions and 41 deletions

View File

@@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/scan/recognition_service.dart';
import '../../shared/models/home_summary.dart';
import '../../shared/models/menu.dart';
import '../menu/menu_provider.dart';
import 'home_service.dart';
// ── Selected date (persists while app is open) ────────────────
@@ -76,3 +78,21 @@ final allJobsProvider =
StateNotifierProvider<AllJobsNotifier, AsyncValue<List<DishJobSummary>>>(
(ref) => AllJobsNotifier(ref.read(recognitionServiceProvider)),
);
// ── Planned meals from menu ────────────────────────────────────
/// Returns planned [MealSlot]s from the menu plan for [dateString].
/// Derives from the already-cached weekly menu — no extra network call.
/// Returns an empty list when no plan exists for that week.
final plannedMealsProvider =
Provider.family<List<MealSlot>, String>((ref, dateString) {
final date = DateTime.parse(dateString);
final weekString = isoWeekString(date);
final menuState = ref.watch(menuProvider(weekString));
final plan = menuState.valueOrNull;
if (plan == null) return [];
for (final day in plan.days) {
if (day.date == dateString) return day.meals;
}
return [];
});

View File

@@ -13,6 +13,7 @@ import '../../core/theme/app_colors.dart';
import '../../shared/models/diary_entry.dart';
import '../../shared/models/home_summary.dart';
import '../../shared/models/meal_type.dart';
import '../../shared/models/menu.dart';
import '../diary/food_search_sheet.dart';
import '../menu/menu_provider.dart';
import '../profile/profile_provider.dart';
@@ -20,6 +21,22 @@ 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 {
@@ -37,6 +54,9 @@ class HomeScreen extends ConsumerWidget {
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 ?? [];
@@ -75,18 +95,22 @@ class HomeScreen extends ConsumerWidget {
ref.read(selectedDateProvider.notifier).state = date,
),
const SizedBox(height: 16),
_CaloriesCard(
loggedCalories: loggedCalories,
dailyGoal: dailyGoal,
goalType: goalType,
),
const SizedBox(height: 12),
_MacrosRow(
proteinG: loggedProtein,
fatG: loggedFat,
carbsG: loggedCarbs,
),
if (todayJobs.isNotEmpty) ...[
if (isFutureDate)
_PlanningBanner(dateString: dateString)
else ...[
_CaloriesCard(
loggedCalories: loggedCalories,
dailyGoal: dailyGoal,
goalType: goalType,
),
const SizedBox(height: 12),
_MacrosRow(
proteinG: loggedProtein,
fatG: loggedFat,
carbsG: loggedCarbs,
),
],
if (!isFutureDate && todayJobs.isNotEmpty) ...[
const SizedBox(height: 16),
_TodayJobsWidget(jobs: todayJobs),
],
@@ -96,13 +120,13 @@ class HomeScreen extends ConsumerWidget {
entries: entries,
dateString: dateString,
),
if (expiringSoon.isNotEmpty) ...[
if (!isFutureDate && expiringSoon.isNotEmpty) ...[
const SizedBox(height: 16),
_ExpiringBanner(items: expiringSoon),
],
const SizedBox(height: 16),
_QuickActionsRow(),
if (recommendations.isNotEmpty) ...[
if (!isFutureDate && recommendations.isNotEmpty) ...[
const SizedBox(height: 20),
_SectionTitle(l10n.recommendCook),
const SizedBox(height: 12),
@@ -163,8 +187,12 @@ class _DateSelector extends StatefulWidget {
}
class _DateSelectorState extends State<_DateSelector> {
// Total days available in the past (index 0 = today, index N-1 = oldest)
static const _totalDays = 365;
// Strip covers 7 future days + today + 364 past days = 372 items total.
// With reverse: true, index 0 is rendered at the RIGHT edge (newest).
// index 0 = today + 7, index 7 = today, index 371 = today - 364.
static const _futureDays = 7;
static const _pastDays = 364;
static const _totalDays = _futureDays + 1 + _pastDays; // 372
static const _pillWidth = 48.0;
static const _pillSpacing = 6.0;
@@ -176,12 +204,16 @@ class _DateSelectorState extends State<_DateSelector> {
return DateFormat('EEE, d MMMM', localeCode).format(date) + yearSuffix;
}
// Index in the reversed list: 0 = today, 1 = yesterday, …
// 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);
return todayNormalized.difference(dateNormalized).inDays.clamp(0, _totalDays - 1);
final daysFromToday =
dateNormalized.difference(todayNormalized).inDays;
return (_futureDays - daysFromToday).clamp(0, _totalDays - 1);
}
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
@@ -192,7 +224,7 @@ class _DateSelectorState extends State<_DateSelector> {
DateTime(previousDay.year, previousDay.month, previousDay.day);
final today = DateTime.now();
final oldestAllowed = DateTime(today.year, today.month, today.day)
.subtract(const Duration(days: _totalDays - 1));
.subtract(const Duration(days: _pastDays));
if (!previousDayNormalized.isBefore(oldestAllowed)) {
widget.onDateSelected(previousDayNormalized);
}
@@ -200,11 +232,12 @@ class _DateSelectorState extends State<_DateSelector> {
void _selectNextDay() {
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
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(todayNormalized)) {
if (!nextDayNormalized.isAfter(futureLimitDate)) {
widget.onDateSelected(nextDayNormalized);
}
}
@@ -214,7 +247,7 @@ class _DateSelectorState extends State<_DateSelector> {
widget.onDateSelected(DateTime(today.year, today.month, today.day));
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
_offsetForIndex(_futureDays),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
@@ -257,6 +290,8 @@ class _DateSelectorState extends State<_DateSelector> {
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,
@@ -298,16 +333,19 @@ class _DateSelectorState extends State<_DateSelector> {
icon: const Icon(Icons.chevron_right),
iconSize: 20,
visualDensity: VisualDensity.compact,
onPressed: null,
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,
// reverse: true → index 0 (today) sits at the right edge
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
@@ -315,9 +353,11 @@ class _DateSelectorState extends State<_DateSelector> {
itemCount: _totalDays,
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
itemBuilder: (listContext, index) {
final date = todayNormalized.subtract(Duration(days: 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),
@@ -327,8 +367,17 @@ class _DateSelectorState extends State<_DateSelector> {
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
: 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,
@@ -338,7 +387,10 @@ class _DateSelectorState extends State<_DateSelector> {
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
: isDayFuture
? theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.7)
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
@@ -352,7 +404,10 @@ class _DateSelectorState extends State<_DateSelector> {
? theme.colorScheme.onPrimary
: isDayToday
? theme.colorScheme.primary
: null,
: isDayFuture
? theme.colorScheme.onSurface
.withValues(alpha: 0.5)
: null,
),
),
],
@@ -714,6 +769,8 @@ class _DailyMealsSection extends ConsumerWidget {
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: [
@@ -725,12 +782,16 @@ class _DailyMealsSection extends ConsumerWidget {
final mealEntries = entries
.where((entry) => entry.mealType == mealTypeId)
.toList();
final mealPlannedSlots = plannedSlots
.where((slot) => slot.mealType == mealTypeId)
.toList();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _MealCard(
mealTypeOption: mealTypeOption,
entries: mealEntries,
dateString: dateString,
plannedSlots: mealPlannedSlots,
),
);
}),
@@ -928,11 +989,13 @@ 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
@@ -942,6 +1005,16 @@ class _MealCard extends ConsumerWidget {
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: [
@@ -986,7 +1059,23 @@ class _MealCard extends ConsumerWidget {
],
),
),
// Diary entries
// 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(
@@ -1054,6 +1143,104 @@ class _DiaryEntryTile extends StatelessWidget {
}
}
// ── 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 {

View File

@@ -29,6 +29,12 @@ final currentWeekProvider = StateProvider<String>((ref) {
return (thu.year, week);
}
/// Returns the ISO 8601 week string for [date], e.g. "2026-W12".
String isoWeekString(DateTime date) {
final (year, week) = _isoWeek(date.toUtc());
return '$year-W${week.toString().padLeft(2, '0')}';
}
// ── Menu notifier ─────────────────────────────────────────────
class MenuNotifier extends StateNotifier<AsyncValue<MenuPlan?>> {