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:
@@ -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 [];
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user