fix: unify date limits, fix ISO week calculation, refactor home screen plan button

- Fix _isoWeek: correct Sunday shift (-3 instead of +4), use floor+1 formula,
  match jan1 timezone to input — planned meals now appear correctly for UTC+ users
- Add kPlanningHorizonDays=28 / kMenuPastWeeks=8 constants; apply to home date
  strip, plan picker (strip + calendar), and menu screen prev/next navigation
- Menu screen week nav: disable arrows at min/max limits using compareTo
- Home screen: replace _GenerateActionCard/_WeekPlannedChip conditional with
  always-visible _FutureDayPlanButton(dateString); show _DayPlannedChip only
  when the specific day has planned meals; remove standalone _PlanMenuButton
- _FutureDayPlanButton uses selected date as defaultStart instead of lastPlanned+1
- Rename weekPlannedLabel -> dayPlannedLabel across all 12 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-22 23:25:46 +02:00
parent edf587e798
commit 9a6b7800a3
30 changed files with 106 additions and 167 deletions

View File

@@ -16,22 +16,27 @@ final menuServiceProvider = Provider<MenuService>((ref) {
/// The ISO week string for the currently displayed week, e.g. "2026-W08".
final currentWeekProvider = StateProvider<String>((ref) {
final now = DateTime.now().toUtc();
final now = DateTime.now();
final (y, w) = _isoWeek(now);
return '$y-W${w.toString().padLeft(2, '0')}';
});
(int year, int week) _isoWeek(DateTime dt) {
// Shift to Thursday to get ISO week year.
final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday)));
final jan1 = DateTime.utc(thu.year, 1, 1);
final week = ((thu.difference(jan1).inDays) / 7).ceil();
// Shift to Thursday of the same ISO week.
// Monday=1…Saturday=6 → add (4 - weekday) days; Sunday=7 → subtract 3 days.
final int shift = dt.weekday == 7 ? -3 : 4 - dt.weekday;
final thu = dt.add(Duration(days: shift));
// Use the same timezone as the input to avoid offset drift on the difference.
final jan1 = dt.isUtc
? DateTime.utc(thu.year, 1, 1)
: DateTime(thu.year, 1, 1);
final week = (thu.difference(jan1).inDays ~/ 7) + 1;
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());
final (year, week) = _isoWeek(date);
return '$year-W${week.toString().padLeft(2, '0')}';
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/constants/date_limits.dart';
import '../../shared/models/menu.dart';
import 'menu_provider.dart';
@@ -95,31 +96,48 @@ class _WeekNavBar extends StatelessWidget implements PreferredSizeWidget {
}
(int, int) _isoWeekOf(DateTime dt) {
final thu = dt.add(Duration(days: 4 - (dt.weekday == 7 ? 0 : dt.weekday)));
final jan1 = DateTime.utc(thu.year, 1, 1);
final w = ((thu.difference(jan1).inDays) / 7).ceil();
final int shift = dt.weekday == 7 ? -3 : 4 - dt.weekday;
final thu = dt.add(Duration(days: shift));
final jan1 = dt.isUtc
? DateTime.utc(thu.year, 1, 1)
: DateTime(thu.year, 1, 1);
final w = (thu.difference(jan1).inDays ~/ 7) + 1;
return (thu.year, w);
}
String _currentWeekString(DateTime date) {
final (y, w) = _isoWeekOf(date);
return '$y-W${w.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final maxWeek = _currentWeekString(
now.add(const Duration(days: kPlanningHorizonDays)));
final minWeek = _currentWeekString(
now.subtract(Duration(days: kMenuPastWeeks * 7)));
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, -1);
},
onPressed: week.compareTo(minWeek) <= 0
? null
: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, -1);
},
),
Text(_weekLabel(week), style: Theme.of(context).textTheme.bodyMedium),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, 1);
},
onPressed: week.compareTo(maxWeek) >= 0
? null
: () {
ref.read(currentWeekProvider.notifier).state =
_offsetWeek(week, 1);
},
),
],
);

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import '../../shared/constants/date_limits.dart';
import '../../shared/models/meal_type.dart';
import '../profile/profile_provider.dart';
import 'menu_provider.dart';
@@ -279,8 +280,8 @@ class _DateStripSelector extends StatefulWidget {
class _DateStripSelectorState extends State<_DateStripSelector> {
late final ScrollController _scrollController;
// Show 30 upcoming days (today excluded, starts tomorrow).
static const _futureDays = 30;
// Show upcoming days up to the planning horizon (today excluded, starts tomorrow).
static const _futureDays = kPlanningHorizonDays;
static const _itemWidth = 64.0;
DateTime get _tomorrow =>
@@ -407,12 +408,22 @@ class _CalendarRangePickerState extends State<_CalendarRangePicker> {
}
void _nextMonth() {
final horizon = DateTime.now().add(const Duration(days: kPlanningHorizonDays));
final limitMonth = DateTime(horizon.year, horizon.month);
if (!DateTime(_displayMonth.year, _displayMonth.month).isBefore(limitMonth)) return;
setState(() {
_displayMonth =
DateTime(_displayMonth.year, _displayMonth.month + 1);
});
}
bool _isBeyondHorizon(DateTime date) {
final today = DateTime.now();
final horizon = DateTime(today.year, today.month, today.day)
.add(const Duration(days: kPlanningHorizonDays));
return date.isAfter(horizon);
}
bool _isInRange(DateTime date) {
final dayOnly = DateTime(date.year, date.month, date.day);
final start =
@@ -510,11 +521,13 @@ class _CalendarRangePickerState extends State<_CalendarRangePicker> {
final isStart = _isRangeStart(date);
final isEnd = _isRangeEnd(date);
final isPast = _isPast(date);
final isBeyond = _isBeyondHorizon(date);
final isDisabled = isPast || isBeyond;
Color bgColor = Colors.transparent;
Color textColor = theme.colorScheme.onSurface;
if (isPast) {
if (isDisabled) {
// ignore: deprecated_member_use
textColor = theme.colorScheme.onSurface.withOpacity(0.3);
} else if (isStart || isEnd) {
@@ -526,7 +539,7 @@ class _CalendarRangePickerState extends State<_CalendarRangePicker> {
}
return GestureDetector(
onTap: isPast ? null : () => widget.onDayTapped(date),
onTap: isDisabled ? null : () => widget.onDayTapped(date),
child: Container(
decoration: BoxDecoration(
color: bgColor,