import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:household_keeper/features/home/presentation/calendar_providers.dart'; /// Number of days in the past and future to show in the strip. const _kPastDays = 90; const _kFutureDays = 90; /// Total number of day cards in the strip. const _kTotalDays = _kPastDays + 1 + _kFutureDays; /// Fixed card width and height for each day card. const _kCardWidth = 56.0; const _kCardHeight = 72.0; /// Default horizontal margin between cards. const _kCardMargin = 4.0; /// Wider gap inserted at month boundaries (left side margin of the first-of-month card). const _kMonthBoundaryGap = 16.0; /// Controller that allows external code (e.g. the Today button) to trigger /// a scroll-to-today animation on the strip. class CalendarStripController { VoidCallback? _scrollToToday; /// Animate the strip to center today's card. void scrollToToday() => _scrollToToday?.call(); } /// A horizontal scrollable strip of day cards spanning [_kPastDays] days in the /// past and [_kFutureDays] days in the future. /// /// Each card shows: /// - German day abbreviation (Mo, Di, Mi, Do, Fr, Sa, So) /// - Date number (day of month) /// /// The selected card is highlighted and always centered. /// Today's card uses bold text + an accent underline bar. /// Month boundaries get a wider gap and a small month label. class CalendarStrip extends ConsumerStatefulWidget { const CalendarStrip({ super.key, required this.controller, required this.onTodayVisibilityChanged, }); /// Controller for programmatic scroll-to-today. final CalendarStripController controller; /// Called when today's card enters or leaves the viewport. final ValueChanged onTodayVisibilityChanged; @override ConsumerState createState() => _CalendarStripState(); } class _CalendarStripState extends ConsumerState { late final ScrollController _scrollController; late final DateTime _today; late final List _dates; @override void initState() { super.initState(); final now = DateTime.now(); _today = DateTime(now.year, now.month, now.day); // Build the date list: _kPastDays before today, today, _kFutureDays after. _dates = List.generate( _kTotalDays, (i) => _today.subtract(Duration(days: _kPastDays - i)), ); // Calculate initial scroll offset so today's card is centered. _scrollController = ScrollController( initialScrollOffset: _offsetForIndex(_kPastDays), ); _scrollController.addListener(_onScroll); // Register the scroll-to-today callback on the controller. widget.controller._scrollToToday = _animateToToday; // After first frame, animate to center today with a short delay so the // strip has laid out its children. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _animateToToday(); // Initial visibility check _onScroll(); } }); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } /// Returns the scroll offset that centers the card at [index]. double _offsetForIndex(int index) { // Sum the widths of all items before [index], then subtract half the viewport // width so the card is centered. We approximate viewport as screen width // because we cannot access it here; we compensate in the post-frame callback. double offset = 0; for (int i = 0; i < index; i++) { offset += _itemWidth(i); } // Center by subtracting half the card container width (will be corrected post-frame). return offset; } /// Returns the total width occupied by the item at [index], including margins /// and any month-boundary gap on its left side. double _itemWidth(int index) { final date = _dates[index]; final leftMargin = _isFirstOfMonth(date) && index > 0 ? _kMonthBoundaryGap : _kCardMargin; // Each item = leftMargin + card width + rightMargin return leftMargin + _kCardWidth + _kCardMargin; } bool _isFirstOfMonth(DateTime date) => date.day == 1; void _animateToToday() { if (!mounted || !_scrollController.hasClients) return; final viewportWidth = _scrollController.position.viewportDimension; double targetOffset = 0; for (int i = 0; i < _kPastDays; i++) { targetOffset += _itemWidth(i); } // Center today's card in the viewport. targetOffset -= (viewportWidth - _kCardWidth) / 2; targetOffset = targetOffset.clamp( _scrollController.position.minScrollExtent, _scrollController.position.maxScrollExtent, ); _scrollController.animateTo( targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } void _animateToIndex(int index) { if (!mounted || !_scrollController.hasClients) return; final viewportWidth = _scrollController.position.viewportDimension; double targetOffset = 0; for (int i = 0; i < index; i++) { targetOffset += _itemWidth(i); } targetOffset -= (viewportWidth - _kCardWidth) / 2; targetOffset = targetOffset.clamp( _scrollController.position.minScrollExtent, _scrollController.position.maxScrollExtent, ); _scrollController.animateTo( targetOffset, duration: const Duration(milliseconds: 250), curve: Curves.easeOut, ); } void _onScroll() { if (!mounted || !_scrollController.hasClients) return; final viewportWidth = _scrollController.position.viewportDimension; final scrollOffset = _scrollController.offset; // Calculate the left edge of today's card. double todayLeftEdge = 0; for (int i = 0; i < _kPastDays; i++) { todayLeftEdge += _itemWidth(i); } final todayRightEdge = todayLeftEdge + _kCardWidth; // Today is visible if any part of the card is in the viewport. final isVisible = todayRightEdge > scrollOffset && todayLeftEdge < scrollOffset + viewportWidth; widget.onTodayVisibilityChanged(isVisible); } void _onCardTapped(int index) { final tappedDate = _dates[index]; ref.read(selectedDateProvider.notifier).selectDate(tappedDate); _animateToIndex(index); } @override Widget build(BuildContext context) { final selectedDate = ref.watch(selectedDateProvider); final theme = Theme.of(context); return SizedBox( height: _kCardHeight + 24, // extra height for month label child: ListView.builder( controller: _scrollController, scrollDirection: Axis.horizontal, itemCount: _kTotalDays, itemBuilder: (context, index) { final date = _dates[index]; final isToday = date == _today; final isSelected = date == selectedDate; final isFirstOfMonth = _isFirstOfMonth(date) && index > 0; return _DayCardItem( date: date, isToday: isToday, isSelected: isSelected, isFirstOfMonth: isFirstOfMonth, onTap: () => _onCardTapped(index), theme: theme, ); }, ), ); } } /// A single day card in the calendar strip, with optional month boundary label. class _DayCardItem extends StatelessWidget { const _DayCardItem({ required this.date, required this.isToday, required this.isSelected, required this.isFirstOfMonth, required this.onTap, required this.theme, }); final DateTime date; final bool isToday; final bool isSelected; final bool isFirstOfMonth; final VoidCallback onTap; final ThemeData theme; @override Widget build(BuildContext context) { final leftMargin = isFirstOfMonth ? _kMonthBoundaryGap : _kCardMargin; // Card background color: selected gets full primaryContainer, others get // a subtle tint of primaryContainer. final bgColor = isSelected ? theme.colorScheme.primaryContainer : theme.colorScheme.primaryContainer.withValues(alpha: 0.3); // Border: selected card gets a primary color border. final border = isSelected ? Border.all(color: theme.colorScheme.primary, width: 1.5) : null; // Text weight: today uses bold. final fontWeight = isToday ? FontWeight.bold : FontWeight.normal; // Day abbreviation (German locale): Mo, Di, Mi, Do, Fr, Sa, So final dayAbbr = DateFormat('E', 'de').format(date); // Date number final dayNum = date.day.toString(); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Month label at boundary if (isFirstOfMonth) Padding( padding: EdgeInsets.only(left: leftMargin), child: SizedBox( width: _kCardWidth + _kCardMargin, child: Text( DateFormat('MMM', 'de').format(date), style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), ), ) else const SizedBox(height: 16), // Reserve space for month label row // Day card GestureDetector( onTap: onTap, child: Container( width: _kCardWidth, height: _kCardHeight, margin: EdgeInsets.only(left: leftMargin, right: _kCardMargin), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(8), border: border, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // German day abbreviation Text( dayAbbr, style: theme.textTheme.labelSmall?.copyWith( fontWeight: fontWeight, color: isSelected ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), const SizedBox(height: 2), // Date number Text( dayNum, style: theme.textTheme.titleMedium?.copyWith( fontWeight: fontWeight, color: isSelected ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onSurface, ), ), // Today accent underline bar const SizedBox(height: 4), if (isToday) Container( width: 20, height: 2, decoration: BoxDecoration( color: theme.colorScheme.primary, borderRadius: BorderRadius.circular(1), ), ) else const SizedBox(height: 2), ], ), ), ), ], ); } }