feat(05-02): build CalendarStrip, CalendarTaskRow, CalendarDayList widgets

- 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
This commit is contained in:
2026-03-16 21:35:35 +01:00
parent 01de2d0f9c
commit f718ee8483
8 changed files with 863 additions and 122 deletions

View File

@@ -0,0 +1,310 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
import 'package:household_keeper/features/home/domain/calendar_models.dart';
import 'package:household_keeper/features/home/presentation/calendar_providers.dart';
import 'package:household_keeper/features/home/presentation/calendar_task_row.dart';
import 'package:household_keeper/features/tasks/presentation/task_providers.dart';
import 'package:household_keeper/l10n/app_localizations.dart';
/// Warm coral/terracotta color for overdue section header.
const _overdueColor = Color(0xFFE07A5F);
/// Shows the task list for the selected calendar day.
///
/// Watches [calendarDayProvider] and renders one of several states:
/// - Loading spinner while data loads
/// - Error text on failure
/// - First-run empty state (no rooms/tasks at all) — prompts to create a room
/// - Empty day state (tasks exist elsewhere but not this day)
/// - Celebration state (today is selected and all tasks are done)
/// - Has-tasks state with optional overdue section (today only) and checkboxes
class CalendarDayList extends ConsumerStatefulWidget {
const CalendarDayList({super.key});
@override
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
}
class _CalendarDayListState extends ConsumerState<CalendarDayList> {
/// Task IDs currently animating out after completion.
final Set<int> _completingTaskIds = {};
void _onTaskCompleted(int taskId) {
setState(() {
_completingTaskIds.add(taskId);
});
ref.read(taskActionsProvider.notifier).completeTask(taskId);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final dayState = ref.watch(calendarDayProvider);
return dayState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text(error.toString())),
data: (state) {
// Clean up animation IDs for tasks that are no longer in the data.
_completingTaskIds.removeWhere((id) =>
!state.overdueTasks.any((t) => t.task.id == id) &&
!state.dayTasks.any((t) => t.task.id == id));
return _buildContent(context, state, l10n, theme);
},
);
}
Widget _buildContent(
BuildContext context,
CalendarDayState state,
AppLocalizations l10n,
ThemeData theme,
) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final isToday = state.selectedDate == today;
// State (a): First-run empty — no tasks exist at all in the database.
if (state.isEmpty && state.totalTaskCount == 0) {
return _buildFirstRunEmpty(context, l10n, theme);
}
// State (e): Celebration — today is selected and all tasks are done
// (totalTaskCount > 0 so at least some task exists somewhere, but today
// has none remaining after completion).
if (isToday && state.dayTasks.isEmpty && state.overdueTasks.isEmpty && state.totalTaskCount > 0) {
return _buildCelebration(l10n, theme);
}
// State (d): Empty day — tasks exist elsewhere but not this day.
if (state.isEmpty) {
return _buildEmptyDay(theme);
}
// State (f): Has tasks — render overdue section (today only) + day tasks.
return _buildTaskList(state, l10n, theme);
}
/// First-run: no rooms/tasks created yet.
Widget _buildFirstRunEmpty(
BuildContext context,
AppLocalizations l10n,
ThemeData theme,
) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.checklist_rounded,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 24),
Text(
l10n.dailyPlanNoTasks,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.homeEmptyMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/rooms'),
child: Text(l10n.homeEmptyAction),
),
],
),
),
);
}
/// Celebration state: today is selected and all tasks are done.
Widget _buildCelebration(AppLocalizations l10n, ThemeData theme) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.celebration_outlined,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(height: 24),
Text(
l10n.dailyPlanAllClearTitle,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.dailyPlanAllClearMessage,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Empty day: tasks exist elsewhere but nothing scheduled for this day.
Widget _buildEmptyDay(ThemeData theme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_available,
size: 48,
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 12),
Text(
'Keine Aufgaben',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
);
}
/// Task list with optional overdue section.
Widget _buildTaskList(
CalendarDayState state,
AppLocalizations l10n,
ThemeData theme,
) {
final items = <Widget>[];
// Overdue section (today only, when overdue tasks exist).
if (state.overdueTasks.isNotEmpty) {
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
color: _overdueColor));
for (final tw in state.overdueTasks) {
items.add(_buildAnimatedTaskRow(tw, isOverdue: true));
}
}
// Day tasks section.
for (final tw in state.dayTasks) {
items.add(_buildAnimatedTaskRow(tw, isOverdue: false));
}
return ListView(children: items);
}
Widget _buildSectionHeader(
String title,
ThemeData theme, {
required Color color,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(color: color),
),
);
}
Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) {
final isCompleting = _completingTaskIds.contains(tw.task.id);
if (isCompleting) {
return _CompletingTaskRow(
key: ValueKey('completing-${tw.task.id}'),
taskWithRoom: tw,
isOverdue: isOverdue,
);
}
return CalendarTaskRow(
key: ValueKey('task-${tw.task.id}'),
taskWithRoom: tw,
isOverdue: isOverdue,
onCompleted: () => _onTaskCompleted(tw.task.id),
);
}
}
/// A task row that animates (slide + size) to zero height on completion.
class _CompletingTaskRow extends StatefulWidget {
const _CompletingTaskRow({
super.key,
required this.taskWithRoom,
required this.isOverdue,
});
final TaskWithRoom taskWithRoom;
final bool isOverdue;
@override
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
}
class _CompletingTaskRowState extends State<_CompletingTaskRow>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _sizeAnimation;
late final Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_sizeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_slideAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(1.0, 0.0),
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _sizeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: CalendarTaskRow(
taskWithRoom: widget.taskWithRoom,
isOverdue: widget.isOverdue,
onCompleted: () {}, // Already completing — ignore repeat taps.
),
),
);
}
}

View File

@@ -57,10 +57,13 @@ final calendarDayProvider =
overdueTasks = const [];
}
final totalTaskCount = await db.calendarDao.getTaskCount();
return CalendarDayState(
selectedDate: selectedDate,
dayTasks: dayTasks,
overdueTasks: overdueTasks,
totalTaskCount: totalTaskCount,
);
});
});

View File

@@ -0,0 +1,348 @@
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),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:household_keeper/features/home/domain/daily_plan_models.dart';
/// Warm coral/terracotta color for overdue task name text.
const _overdueColor = Color(0xFFE07A5F);
/// A task row adapted for the calendar day list.
///
/// Shows task name, a tappable room tag (navigates to room task list),
/// and an interactive checkbox. Does NOT show a relative date — the
/// calendar strip already communicates which day is selected.
///
/// When [isOverdue] is true the task name uses coral text to visually
/// distinguish overdue carry-over from today's regular tasks.
class CalendarTaskRow extends StatelessWidget {
const CalendarTaskRow({
super.key,
required this.taskWithRoom,
required this.onCompleted,
this.isOverdue = false,
});
final TaskWithRoom taskWithRoom;
/// Called when the user checks the checkbox.
final VoidCallback onCompleted;
/// When true, task name is rendered in coral color.
final bool isOverdue;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final task = taskWithRoom.task;
return ListTile(
leading: Checkbox(
value: false,
onChanged: (_) => onCompleted(),
),
title: Text(
task.name,
style: theme.textTheme.titleMedium?.copyWith(
color: isOverdue ? _overdueColor : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: GestureDetector(
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
taskWithRoom.roomName,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondaryContainer,
),
),
),
),
);
}
}