- Add totalTaskCount field to CalendarDayState to distinguish first-run from celebration - Add getTaskCount() to CalendarDao (SELECT COUNT from tasks) - CalendarStrip: 181-day horizontal scroll with German abbreviations, today highlighting, month boundary labels, scroll-to-today controller - CalendarTaskRow: task name + room tag chip + checkbox, no relative date, isOverdue coral styling - CalendarDayList: loading/error/first-run-empty/empty-day/celebration/has-tasks states, overdue section (today only), slide-out completion animation - Update home_screen_test.dart and app_shell_test.dart to test new calendar providers instead of dailyPlanProvider
349 lines
11 KiB
Dart
349 lines
11 KiB
Dart
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<bool> onTodayVisibilityChanged;
|
|
|
|
@override
|
|
ConsumerState<CalendarStrip> createState() => _CalendarStripState();
|
|
}
|
|
|
|
class _CalendarStripState extends ConsumerState<CalendarStrip> {
|
|
late final ScrollController _scrollController;
|
|
late final DateTime _today;
|
|
late final List<DateTime> _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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|