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 '../../features/scan/recognition_service.dart';
|
||||||
import '../../shared/models/home_summary.dart';
|
import '../../shared/models/home_summary.dart';
|
||||||
|
import '../../shared/models/menu.dart';
|
||||||
|
import '../menu/menu_provider.dart';
|
||||||
import 'home_service.dart';
|
import 'home_service.dart';
|
||||||
|
|
||||||
// ── Selected date (persists while app is open) ────────────────
|
// ── Selected date (persists while app is open) ────────────────
|
||||||
@@ -76,3 +78,21 @@ final allJobsProvider =
|
|||||||
StateNotifierProvider<AllJobsNotifier, AsyncValue<List<DishJobSummary>>>(
|
StateNotifierProvider<AllJobsNotifier, AsyncValue<List<DishJobSummary>>>(
|
||||||
(ref) => AllJobsNotifier(ref.read(recognitionServiceProvider)),
|
(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/diary_entry.dart';
|
||||||
import '../../shared/models/home_summary.dart';
|
import '../../shared/models/home_summary.dart';
|
||||||
import '../../shared/models/meal_type.dart';
|
import '../../shared/models/meal_type.dart';
|
||||||
|
import '../../shared/models/menu.dart';
|
||||||
import '../diary/food_search_sheet.dart';
|
import '../diary/food_search_sheet.dart';
|
||||||
import '../menu/menu_provider.dart';
|
import '../menu/menu_provider.dart';
|
||||||
import '../profile/profile_provider.dart';
|
import '../profile/profile_provider.dart';
|
||||||
@@ -20,6 +21,22 @@ import '../scan/dish_result_screen.dart';
|
|||||||
import '../scan/recognition_service.dart';
|
import '../scan/recognition_service.dart';
|
||||||
import 'home_provider.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 ───────────────────────────────────────────────
|
// ── Root screen ───────────────────────────────────────────────
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
@@ -37,6 +54,9 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
final selectedDate = ref.watch(selectedDateProvider);
|
final selectedDate = ref.watch(selectedDateProvider);
|
||||||
final dateString = formatDateForDiary(selectedDate);
|
final dateString = formatDateForDiary(selectedDate);
|
||||||
|
final dateContext = _contextFor(selectedDate);
|
||||||
|
final isFutureDate = dateContext == _DateContext.future;
|
||||||
|
|
||||||
final diaryState = ref.watch(diaryProvider(dateString));
|
final diaryState = ref.watch(diaryProvider(dateString));
|
||||||
final entries = diaryState.valueOrNull ?? [];
|
final entries = diaryState.valueOrNull ?? [];
|
||||||
|
|
||||||
@@ -75,6 +95,9 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
ref.read(selectedDateProvider.notifier).state = date,
|
ref.read(selectedDateProvider.notifier).state = date,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (isFutureDate)
|
||||||
|
_PlanningBanner(dateString: dateString)
|
||||||
|
else ...[
|
||||||
_CaloriesCard(
|
_CaloriesCard(
|
||||||
loggedCalories: loggedCalories,
|
loggedCalories: loggedCalories,
|
||||||
dailyGoal: dailyGoal,
|
dailyGoal: dailyGoal,
|
||||||
@@ -86,7 +109,8 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
fatG: loggedFat,
|
fatG: loggedFat,
|
||||||
carbsG: loggedCarbs,
|
carbsG: loggedCarbs,
|
||||||
),
|
),
|
||||||
if (todayJobs.isNotEmpty) ...[
|
],
|
||||||
|
if (!isFutureDate && todayJobs.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_TodayJobsWidget(jobs: todayJobs),
|
_TodayJobsWidget(jobs: todayJobs),
|
||||||
],
|
],
|
||||||
@@ -96,13 +120,13 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
entries: entries,
|
entries: entries,
|
||||||
dateString: dateString,
|
dateString: dateString,
|
||||||
),
|
),
|
||||||
if (expiringSoon.isNotEmpty) ...[
|
if (!isFutureDate && expiringSoon.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ExpiringBanner(items: expiringSoon),
|
_ExpiringBanner(items: expiringSoon),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_QuickActionsRow(),
|
_QuickActionsRow(),
|
||||||
if (recommendations.isNotEmpty) ...[
|
if (!isFutureDate && recommendations.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_SectionTitle(l10n.recommendCook),
|
_SectionTitle(l10n.recommendCook),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -163,8 +187,12 @@ class _DateSelector extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DateSelectorState extends State<_DateSelector> {
|
class _DateSelectorState extends State<_DateSelector> {
|
||||||
// Total days available in the past (index 0 = today, index N-1 = oldest)
|
// Strip covers 7 future days + today + 364 past days = 372 items total.
|
||||||
static const _totalDays = 365;
|
// 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 _pillWidth = 48.0;
|
||||||
static const _pillSpacing = 6.0;
|
static const _pillSpacing = 6.0;
|
||||||
|
|
||||||
@@ -176,12 +204,16 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
return DateFormat('EEE, d MMMM', localeCode).format(date) + yearSuffix;
|
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) {
|
int _indexForDate(DateTime date) {
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||||
final dateNormalized = DateTime(date.year, date.month, date.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);
|
double _offsetForIndex(int index) => index * (_pillWidth + _pillSpacing);
|
||||||
@@ -192,7 +224,7 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
DateTime(previousDay.year, previousDay.month, previousDay.day);
|
DateTime(previousDay.year, previousDay.month, previousDay.day);
|
||||||
final today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final oldestAllowed = DateTime(today.year, today.month, today.day)
|
final oldestAllowed = DateTime(today.year, today.month, today.day)
|
||||||
.subtract(const Duration(days: _totalDays - 1));
|
.subtract(const Duration(days: _pastDays));
|
||||||
if (!previousDayNormalized.isBefore(oldestAllowed)) {
|
if (!previousDayNormalized.isBefore(oldestAllowed)) {
|
||||||
widget.onDateSelected(previousDayNormalized);
|
widget.onDateSelected(previousDayNormalized);
|
||||||
}
|
}
|
||||||
@@ -200,11 +232,12 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
|
|
||||||
void _selectNextDay() {
|
void _selectNextDay() {
|
||||||
final today = DateTime.now();
|
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 nextDay = widget.selectedDate.add(const Duration(days: 1));
|
||||||
final nextDayNormalized =
|
final nextDayNormalized =
|
||||||
DateTime(nextDay.year, nextDay.month, nextDay.day);
|
DateTime(nextDay.year, nextDay.month, nextDay.day);
|
||||||
if (!nextDayNormalized.isAfter(todayNormalized)) {
|
if (!nextDayNormalized.isAfter(futureLimitDate)) {
|
||||||
widget.onDateSelected(nextDayNormalized);
|
widget.onDateSelected(nextDayNormalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +247,7 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
widget.onDateSelected(DateTime(today.year, today.month, today.day));
|
widget.onDateSelected(DateTime(today.year, today.month, today.day));
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
0,
|
_offsetForIndex(_futureDays),
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
);
|
);
|
||||||
@@ -257,6 +290,8 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
final selectedNormalized = DateTime(
|
final selectedNormalized = DateTime(
|
||||||
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
|
widget.selectedDate.year, widget.selectedDate.month, widget.selectedDate.day);
|
||||||
final isToday = selectedNormalized == todayNormalized;
|
final isToday = selectedNormalized == todayNormalized;
|
||||||
|
final futureLimitDate =
|
||||||
|
todayNormalized.add(const Duration(days: _futureDays));
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -298,16 +333,19 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_right),
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
onPressed: null,
|
onPressed: selectedNormalized.isBefore(futureLimitDate)
|
||||||
|
? _selectNextDay
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// ── Day strip ────────────────────────────────────────────
|
// ── Day strip ────────────────────────────────────────────
|
||||||
|
// reverse: true → index 0 (7 days from now) at the right edge;
|
||||||
|
// index _futureDays = today; past dates have higher indices.
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 56,
|
height: 56,
|
||||||
// reverse: true → index 0 (today) sits at the right edge
|
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
@@ -315,9 +353,11 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
itemCount: _totalDays,
|
itemCount: _totalDays,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
|
separatorBuilder: (_, __) => const SizedBox(width: _pillSpacing),
|
||||||
itemBuilder: (listContext, index) {
|
itemBuilder: (listContext, index) {
|
||||||
final date = todayNormalized.subtract(Duration(days: index));
|
final date = todayNormalized
|
||||||
|
.add(Duration(days: _futureDays - index));
|
||||||
final isSelected = date == selectedNormalized;
|
final isSelected = date == selectedNormalized;
|
||||||
final isDayToday = date == todayNormalized;
|
final isDayToday = date == todayNormalized;
|
||||||
|
final isDayFuture = date.isAfter(todayNormalized);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => widget.onDateSelected(date),
|
onTap: () => widget.onDateSelected(date),
|
||||||
@@ -327,8 +367,17 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
|
: isDayFuture
|
||||||
|
? theme.colorScheme.surfaceContainerLow
|
||||||
: theme.colorScheme.surfaceContainerHighest,
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isDayFuture && !isSelected
|
||||||
|
? Border.all(
|
||||||
|
color: theme.colorScheme.outline
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -338,6 +387,9 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
style: theme.textTheme.labelSmall?.copyWith(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? theme.colorScheme.onPrimary
|
? theme.colorScheme.onPrimary
|
||||||
|
: isDayFuture
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.7)
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -352,6 +404,9 @@ class _DateSelectorState extends State<_DateSelector> {
|
|||||||
? theme.colorScheme.onPrimary
|
? theme.colorScheme.onPrimary
|
||||||
: isDayToday
|
: isDayToday
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
|
: isDayFuture
|
||||||
|
? theme.colorScheme.onSurface
|
||||||
|
.withValues(alpha: 0.5)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -714,6 +769,8 @@ class _DailyMealsSection extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final plannedSlots = ref.watch(plannedMealsProvider(dateString));
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -725,12 +782,16 @@ class _DailyMealsSection extends ConsumerWidget {
|
|||||||
final mealEntries = entries
|
final mealEntries = entries
|
||||||
.where((entry) => entry.mealType == mealTypeId)
|
.where((entry) => entry.mealType == mealTypeId)
|
||||||
.toList();
|
.toList();
|
||||||
|
final mealPlannedSlots = plannedSlots
|
||||||
|
.where((slot) => slot.mealType == mealTypeId)
|
||||||
|
.toList();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: _MealCard(
|
child: _MealCard(
|
||||||
mealTypeOption: mealTypeOption,
|
mealTypeOption: mealTypeOption,
|
||||||
entries: mealEntries,
|
entries: mealEntries,
|
||||||
dateString: dateString,
|
dateString: dateString,
|
||||||
|
plannedSlots: mealPlannedSlots,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -928,11 +989,13 @@ class _MealCard extends ConsumerWidget {
|
|||||||
final MealTypeOption mealTypeOption;
|
final MealTypeOption mealTypeOption;
|
||||||
final List<DiaryEntry> entries;
|
final List<DiaryEntry> entries;
|
||||||
final String dateString;
|
final String dateString;
|
||||||
|
final List<MealSlot> plannedSlots;
|
||||||
|
|
||||||
const _MealCard({
|
const _MealCard({
|
||||||
required this.mealTypeOption,
|
required this.mealTypeOption,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
required this.dateString,
|
required this.dateString,
|
||||||
|
this.plannedSlots = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -942,6 +1005,16 @@ class _MealCard extends ConsumerWidget {
|
|||||||
final totalCalories = entries.fold<double>(
|
final totalCalories = entries.fold<double>(
|
||||||
0.0, (sum, entry) => sum + (entry.calories ?? 0));
|
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(
|
return Card(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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) ...[
|
if (entries.isNotEmpty) ...[
|
||||||
const Divider(height: 1, indent: 16),
|
const Divider(height: 1, indent: 16),
|
||||||
...entries.map((entry) => _DiaryEntryTile(
|
...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 ───────────────────────────────────────────
|
// ── Expiring banner ───────────────────────────────────────────
|
||||||
|
|
||||||
class _ExpiringBanner extends StatelessWidget {
|
class _ExpiringBanner extends StatelessWidget {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ final currentWeekProvider = StateProvider<String>((ref) {
|
|||||||
return (thu.year, week);
|
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 ─────────────────────────────────────────────
|
// ── Menu notifier ─────────────────────────────────────────────
|
||||||
|
|
||||||
class MenuNotifier extends StateNotifier<AsyncValue<MenuPlan?>> {
|
class MenuNotifier extends StateNotifier<AsyncValue<MenuPlan?>> {
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "حصص",
|
"servingsLabel": "حصص",
|
||||||
"addToDiary": "إضافة إلى اليومية",
|
"addToDiary": "إضافة إلى اليومية",
|
||||||
"scanDishPhoto": "مسح الصورة"
|
"scanDishPhoto": "مسح الصورة",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Portionen",
|
"servingsLabel": "Portionen",
|
||||||
"addToDiary": "Zum Tagebuch hinzufügen",
|
"addToDiary": "Zum Tagebuch hinzufügen",
|
||||||
"scanDishPhoto": "Foto scannen"
|
"scanDishPhoto": "Foto scannen",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,5 +121,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Servings",
|
"servingsLabel": "Servings",
|
||||||
"addToDiary": "Add to diary",
|
"addToDiary": "Add to diary",
|
||||||
"scanDishPhoto": "Scan photo"
|
"scanDishPhoto": "Scan photo",
|
||||||
|
"planningForDate": "Planning for {date}",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "Mark as eaten",
|
||||||
|
"plannedMealLabel": "Planned"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Porciones",
|
"servingsLabel": "Porciones",
|
||||||
"addToDiary": "Añadir al diario",
|
"addToDiary": "Añadir al diario",
|
||||||
"scanDishPhoto": "Escanear foto"
|
"scanDishPhoto": "Escanear foto",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Portions",
|
"servingsLabel": "Portions",
|
||||||
"addToDiary": "Ajouter au journal",
|
"addToDiary": "Ajouter au journal",
|
||||||
"scanDishPhoto": "Scanner une photo"
|
"scanDishPhoto": "Scanner une photo",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "सर्विंग",
|
"servingsLabel": "सर्विंग",
|
||||||
"addToDiary": "डायरी में जोड़ें",
|
"addToDiary": "डायरी में जोड़ें",
|
||||||
"scanDishPhoto": "फ़ोटो स्कैन करें"
|
"scanDishPhoto": "फ़ोटो स्कैन करें",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Porzioni",
|
"servingsLabel": "Porzioni",
|
||||||
"addToDiary": "Aggiungi al diario",
|
"addToDiary": "Aggiungi al diario",
|
||||||
"scanDishPhoto": "Scansiona foto"
|
"scanDishPhoto": "Scansiona foto",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "人前",
|
"servingsLabel": "人前",
|
||||||
"addToDiary": "日記に追加",
|
"addToDiary": "日記に追加",
|
||||||
"scanDishPhoto": "写真をスキャン"
|
"scanDishPhoto": "写真をスキャン",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "인분",
|
"servingsLabel": "인분",
|
||||||
"addToDiary": "일기에 추가",
|
"addToDiary": "일기에 추가",
|
||||||
"scanDishPhoto": "사진 스캔"
|
"scanDishPhoto": "사진 스캔",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -789,6 +789,24 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Scan photo'**
|
/// **'Scan photo'**
|
||||||
String get scanDishPhoto;
|
String get scanDishPhoto;
|
||||||
|
|
||||||
|
/// No description provided for @planningForDate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Planning for {date}'**
|
||||||
|
String planningForDate(String date);
|
||||||
|
|
||||||
|
/// No description provided for @markAsEaten.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Mark as eaten'**
|
||||||
|
String get markAsEaten;
|
||||||
|
|
||||||
|
/// No description provided for @plannedMealLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Planned'**
|
||||||
|
String get plannedMealLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -348,4 +348,15 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'مسح الصورة';
|
String get scanDishPhoto => 'مسح الصورة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Foto scannen';
|
String get scanDishPhoto => 'Foto scannen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,4 +348,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Scan photo';
|
String get scanDishPhoto => 'Scan photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return 'Planning for $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => 'Mark as eaten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => 'Planned';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,15 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Escanear foto';
|
String get scanDishPhoto => 'Escanear foto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,4 +351,15 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Scanner une photo';
|
String get scanDishPhoto => 'Scanner une photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,4 +349,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'फ़ोटो स्कैन करें';
|
String get scanDishPhoto => 'फ़ोटो स्कैन करें';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,15 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Scansiona foto';
|
String get scanDishPhoto => 'Scansiona foto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,4 +347,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => '写真をスキャン';
|
String get scanDishPhoto => '写真をスキャン';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,4 +347,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => '사진 스캔';
|
String get scanDishPhoto => '사진 스캔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,4 +350,15 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Escanear foto';
|
String get scanDishPhoto => 'Escanear foto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,4 +348,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => 'Сканировать фото';
|
String get scanDishPhoto => 'Сканировать фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return 'Планирование на $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => 'Отметить как съеденное';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => 'Запланировано';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,4 +347,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get scanDishPhoto => '扫描照片';
|
String get scanDishPhoto => '扫描照片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String planningForDate(String date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get markAsEaten => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get plannedMealLabel => '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Porções",
|
"servingsLabel": "Porções",
|
||||||
"addToDiary": "Adicionar ao diário",
|
"addToDiary": "Adicionar ao diário",
|
||||||
"scanDishPhoto": "Escanear foto"
|
"scanDishPhoto": "Escanear foto",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,5 +121,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "Порций",
|
"servingsLabel": "Порций",
|
||||||
"addToDiary": "Добавить в дневник",
|
"addToDiary": "Добавить в дневник",
|
||||||
"scanDishPhoto": "Сканировать фото"
|
"scanDishPhoto": "Сканировать фото",
|
||||||
|
"planningForDate": "Планирование на {date}",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "Отметить как съеденное",
|
||||||
|
"plannedMealLabel": "Запланировано"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,5 +123,13 @@
|
|||||||
},
|
},
|
||||||
"servingsLabel": "份数",
|
"servingsLabel": "份数",
|
||||||
"addToDiary": "添加到日记",
|
"addToDiary": "添加到日记",
|
||||||
"scanDishPhoto": "扫描照片"
|
"scanDishPhoto": "扫描照片",
|
||||||
|
"planningForDate": "",
|
||||||
|
"@planningForDate": {
|
||||||
|
"placeholders": {
|
||||||
|
"date": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsEaten": "",
|
||||||
|
"plannedMealLabel": ""
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user