diff --git a/lib/features/home/data/calendar_dao.dart b/lib/features/home/data/calendar_dao.dart index 62e515b..3cac74d 100644 --- a/lib/features/home/data/calendar_dao.dart +++ b/lib/features/home/data/calendar_dao.dart @@ -45,6 +45,16 @@ class CalendarDao extends DatabaseAccessor }); } + /// Returns the total count of tasks across all rooms and dates. + /// + /// Used by the UI to distinguish first-run empty state from celebration state. + Future getTaskCount() async { + final countExp = tasks.id.count(); + final query = selectOnly(tasks)..addColumns([countExp]); + final result = await query.getSingle(); + return result.read(countExp) ?? 0; + } + /// Watch tasks whose [nextDueDate] is strictly before [referenceDate]. /// /// Returns tasks sorted by [nextDueDate] ascending (oldest first). diff --git a/lib/features/home/domain/calendar_models.dart b/lib/features/home/domain/calendar_models.dart index c9c7d1d..77b2e87 100644 --- a/lib/features/home/domain/calendar_models.dart +++ b/lib/features/home/domain/calendar_models.dart @@ -6,10 +6,16 @@ class CalendarDayState { final List dayTasks; final List overdueTasks; + /// Total number of tasks in the database (across all days/rooms). + /// Used by the UI to distinguish first-run empty state (no tasks exist at all) + /// from celebration state (tasks exist but today's are all done). + final int totalTaskCount; + const CalendarDayState({ required this.selectedDate, required this.dayTasks, required this.overdueTasks, + required this.totalTaskCount, }); /// True when both day tasks and overdue tasks are empty. diff --git a/lib/features/home/presentation/calendar_day_list.dart b/lib/features/home/presentation/calendar_day_list.dart new file mode 100644 index 0000000..b766761 --- /dev/null +++ b/lib/features/home/presentation/calendar_day_list.dart @@ -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 createState() => _CalendarDayListState(); +} + +class _CalendarDayListState extends ConsumerState { + /// Task IDs currently animating out after completion. + final Set _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 = []; + + // 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 _sizeAnimation; + late final Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _sizeAnimation = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _slideAnimation = Tween( + 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. + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/calendar_providers.dart b/lib/features/home/presentation/calendar_providers.dart index c980dce..0f78873 100644 --- a/lib/features/home/presentation/calendar_providers.dart +++ b/lib/features/home/presentation/calendar_providers.dart @@ -57,10 +57,13 @@ final calendarDayProvider = overdueTasks = const []; } + final totalTaskCount = await db.calendarDao.getTaskCount(); + return CalendarDayState( selectedDate: selectedDate, dayTasks: dayTasks, overdueTasks: overdueTasks, + totalTaskCount: totalTaskCount, ); }); }); diff --git a/lib/features/home/presentation/calendar_strip.dart b/lib/features/home/presentation/calendar_strip.dart new file mode 100644 index 0000000..4099e70 --- /dev/null +++ b/lib/features/home/presentation/calendar_strip.dart @@ -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 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), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/home/presentation/calendar_task_row.dart b/lib/features/home/presentation/calendar_task_row.dart new file mode 100644 index 0000000..daa9ac3 --- /dev/null +++ b/lib/features/home/presentation/calendar_task_row.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/test/features/home/presentation/home_screen_test.dart b/test/features/home/presentation/home_screen_test.dart index 6c13588..0df2025 100644 --- a/test/features/home/presentation/home_screen_test.dart +++ b/test/features/home/presentation/home_screen_test.dart @@ -5,12 +5,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:household_keeper/core/database/database.dart'; import 'package:household_keeper/core/router/router.dart'; -import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; -import 'package:household_keeper/features/home/presentation/daily_plan_providers.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/rooms/presentation/room_providers.dart'; import 'package:household_keeper/features/tasks/domain/effort_level.dart'; import 'package:household_keeper/features/tasks/domain/frequency.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; +import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; /// Helper to create a test [Task] with sensible defaults. Task _makeTask({ @@ -51,15 +52,16 @@ TaskWithRoom _makeTaskWithRoom({ ); } -/// Build the app with dailyPlanProvider overridden to the given state. +/// Build the app with calendarDayProvider overridden to the given state. /// /// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid /// the riverpod_lint scoped_providers_should_specify_dependencies warning. -Widget _buildApp(DailyPlanState planState) { +Widget _buildApp(CalendarDayState dayState) { final container = ProviderContainer(overrides: [ - dailyPlanProvider.overrideWith( - (ref) => Stream.value(planState), + calendarDayProvider.overrideWith( + (ref) => Stream.value(dayState), ), + selectedDateProvider.overrideWith(SelectedDateNotifier.new), roomWithStatsListProvider.overrideWith( (ref) => Stream.value([]), ), @@ -81,17 +83,21 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + group('HomeScreen empty states', () { testWidgets('shows no-tasks empty state when no tasks exist at all', (tester) async { - await tester.pumpWidget(_buildApp(const DailyPlanState( - overdueTasks: [], - todayTasks: [], - tomorrowTasks: [], - completedTodayCount: 0, - totalTodayCount: 0, + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 0, ))); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Should show "Noch keine Aufgaben angelegt" (dailyPlanNoTasks) expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget); @@ -99,58 +105,53 @@ void main() { expect(find.text('Raum erstellen'), findsOneWidget); }); - testWidgets('shows all-clear state when all tasks are done', + testWidgets('shows celebration state when tasks exist but today is clear', (tester) async { - await tester.pumpWidget(_buildApp(const DailyPlanState( - overdueTasks: [], - todayTasks: [], - tomorrowTasks: [], - completedTodayCount: 3, - totalTodayCount: 3, + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 5, // tasks exist elsewhere ))); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); - // Should show celebration empty state - expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget); + // Should show celebration state expect(find.byIcon(Icons.celebration_outlined), findsOneWidget); - // Progress card should show 3/3 - expect(find.text('3 von 3 erledigt'), findsOneWidget); + expect(find.text('Alles erledigt! \u{1F31F}'), findsOneWidget); + }); + + testWidgets('shows empty-day state for non-today date with no tasks', + (tester) async { + final tomorrow = today.add(const Duration(days: 1)); + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: tomorrow, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 5, // tasks exist on other days + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Should show "Keine Aufgaben" (not celebration — not today) + expect(find.text('Keine Aufgaben'), findsOneWidget); + expect(find.byIcon(Icons.event_available), findsOneWidget); }); }); group('HomeScreen normal state', () { - testWidgets('shows progress card with correct counts', (tester) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - - await tester.pumpWidget(_buildApp(DailyPlanState( - overdueTasks: [], - todayTasks: [ + testWidgets('shows overdue section when overdue tasks exist (today)', + (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: [ _makeTaskWithRoom( - id: 1, + id: 2, taskName: 'Staubsaugen', roomName: 'Wohnzimmer', nextDueDate: today, ), ], - tomorrowTasks: [], - completedTodayCount: 2, - totalTodayCount: 3, - ))); - await tester.pumpAndSettle(); - - // Progress card should show 2/3 - expect(find.text('2 von 3 erledigt'), findsOneWidget); - expect(find.byType(LinearProgressIndicator), findsOneWidget); - }); - - testWidgets('shows overdue section when overdue tasks exist', - (tester) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - - await tester.pumpWidget(_buildApp(DailyPlanState( overdueTasks: [ _makeTaskWithRoom( id: 1, @@ -159,24 +160,13 @@ void main() { nextDueDate: yesterday, ), ], - todayTasks: [ - _makeTaskWithRoom( - id: 2, - taskName: 'Staubsaugen', - roomName: 'Wohnzimmer', - nextDueDate: today, - ), - ], - tomorrowTasks: [], - completedTodayCount: 0, - totalTodayCount: 2, + totalTaskCount: 2, ))); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Should show overdue section header expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget); - // Should show today section header (may also appear as relative date) - expect(find.text('Heute'), findsAtLeast(1)); // Should show both tasks expect(find.text('Boden wischen'), findsOneWidget); expect(find.text('Staubsaugen'), findsOneWidget); @@ -185,54 +175,37 @@ void main() { expect(find.text('Wohnzimmer'), findsOneWidget); }); - testWidgets('shows collapsed tomorrow section with count', + testWidgets('does not show overdue section for non-today date', (tester) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); + // On a future date, overdueTasks will be empty (calendarDayProvider + // only populates overdueTasks when isToday). final tomorrow = today.add(const Duration(days: 1)); - - await tester.pumpWidget(_buildApp(DailyPlanState( - overdueTasks: [], - todayTasks: [ + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: tomorrow, + dayTasks: [ _makeTaskWithRoom( id: 1, taskName: 'Staubsaugen', roomName: 'Wohnzimmer', - nextDueDate: today, - ), - ], - tomorrowTasks: [ - _makeTaskWithRoom( - id: 2, - taskName: 'Fenster putzen', - roomName: 'Schlafzimmer', - nextDueDate: tomorrow, - ), - _makeTaskWithRoom( - id: 3, - taskName: 'Bett beziehen', - roomName: 'Schlafzimmer', nextDueDate: tomorrow, ), ], - completedTodayCount: 0, - totalTodayCount: 1, + overdueTasks: const [], // No overdue for non-today + totalTaskCount: 1, ))); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); - // Should show collapsed tomorrow section with count - expect(find.text('Demn\u00e4chst (2)'), findsOneWidget); - // Tomorrow tasks should NOT be visible (collapsed by default) - expect(find.text('Fenster putzen'), findsNothing); + // Should NOT show overdue section header + expect(find.text('\u00dcberf\u00e4llig'), findsNothing); + // Should show day task + expect(find.text('Staubsaugen'), findsOneWidget); }); - testWidgets('today tasks have checkboxes', (tester) async { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - - await tester.pumpWidget(_buildApp(DailyPlanState( - overdueTasks: [], - todayTasks: [ + testWidgets('tasks have checkboxes', (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: [ _makeTaskWithRoom( id: 1, taskName: 'Staubsaugen', @@ -240,14 +213,29 @@ void main() { nextDueDate: today, ), ], - tomorrowTasks: [], - completedTodayCount: 0, - totalTodayCount: 1, + overdueTasks: const [], + totalTaskCount: 1, ))); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); - // Today task should have a checkbox + // Task should have a checkbox expect(find.byType(Checkbox), findsOneWidget); }); + + testWidgets('calendar strip is shown', (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 0, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // The strip is a horizontal ListView — verify it exists by finding + // ListView widgets (strip + potentially the task list). + expect(find.byType(ListView), findsWidgets); + }); }); } diff --git a/test/shell/app_shell_test.dart b/test/shell/app_shell_test.dart index ff978eb..ddebf3f 100644 --- a/test/shell/app_shell_test.dart +++ b/test/shell/app_shell_test.dart @@ -4,8 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:household_keeper/core/router/router.dart'; -import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; -import 'package:household_keeper/features/home/presentation/daily_plan_providers.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/rooms/presentation/room_providers.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; @@ -15,6 +15,9 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + /// Helper to build the app with providers overridden for testing. /// /// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid @@ -26,15 +29,16 @@ void main() { roomWithStatsListProvider.overrideWith( (ref) => Stream.value([]), ), - // Override daily plan to return empty state so HomeScreen - // renders without a database. - dailyPlanProvider.overrideWith( - (ref) => Stream.value(const DailyPlanState( - overdueTasks: [], - todayTasks: [], - tomorrowTasks: [], - completedTodayCount: 0, - totalTodayCount: 0, + // Override selected date to avoid any DB access. + selectedDateProvider.overrideWith(SelectedDateNotifier.new), + // Override calendar day provider to return empty first-run state so + // HomeScreen renders without a database. + calendarDayProvider.overrideWith( + (ref) => Stream.value(CalendarDayState( + selectedDate: today, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 0, )), ), ]); @@ -53,7 +57,8 @@ void main() { testWidgets('renders 3 navigation destinations with correct German labels', (tester) async { await tester.pumpWidget(buildApp()); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Verify 3 NavigationDestination widgets are rendered expect(find.byType(NavigationDestination), findsNWidgets(3)); @@ -67,22 +72,24 @@ void main() { testWidgets('tapping a destination changes the selected tab', (tester) async { await tester.pumpWidget(buildApp()); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); - // Initially on Home tab (index 0) -- verify home empty state is shown - // (dailyPlanNoTasks text from the daily plan empty state) + // Initially on Home tab (index 0) -- verify home first-run empty state expect(find.text('Noch keine Aufgaben angelegt'), findsOneWidget); // Tap the Rooms tab (second destination) await tester.tap(find.text('R\u00e4ume')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Verify we see Rooms content now (empty state) expect(find.text('Hier ist noch alles leer!'), findsOneWidget); // Tap the Settings tab (third destination) await tester.tap(find.text('Einstellungen')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); // Verify we see Settings content now expect(find.text('Darstellung'), findsOneWidget);