From d220dbe5ce8dc1d076302bc1f14e06b8d82fcf5d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 23:35:17 +0100 Subject: [PATCH] test(TaskListScreen): add integration tests for filtered and overdue task states - Covers empty states, celebration state, and scheduled/overdue task rendering - Verifies proper checkbox behavior for future tasks - Tests AppBar for sort dropdown, edit/delete actions, and calendar strip - Adds necessary test helpers and overrides for room-specific tasks --- lib/features/home/data/calendar_dao.dart | 73 ++++ .../home/presentation/calendar_day_list.dart | 85 ++++- .../home/presentation/calendar_providers.dart | 40 +++ .../home/presentation/calendar_task_row.dart | 45 ++- .../tasks/presentation/task_list_screen.dart | 188 +++------- lib/features/tasks/presentation/task_row.dart | 11 +- .../features/home/data/calendar_dao_test.dart | 263 ++++++++++++++ .../presentation/task_list_screen_test.dart | 325 ++++++++++++++++++ 8 files changed, 869 insertions(+), 161 deletions(-) create mode 100644 test/features/tasks/presentation/task_list_screen_test.dart diff --git a/lib/features/home/data/calendar_dao.dart b/lib/features/home/data/calendar_dao.dart index 3cac74d..21ac5a2 100644 --- a/lib/features/home/data/calendar_dao.dart +++ b/lib/features/home/data/calendar_dao.dart @@ -55,6 +55,33 @@ class CalendarDao extends DatabaseAccessor return result.read(countExp) ?? 0; } + /// Watch tasks due on [date] within a specific [roomId]. + /// + /// Same as [watchTasksForDate] but filtered to a single room. + Stream> watchTasksForDateInRoom( + DateTime date, int roomId) { + final startOfDay = DateTime(date.year, date.month, date.day); + final endOfDay = startOfDay.add(const Duration(days: 1)); + + final query = select(tasks).join([ + innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)), + ]); + query.where( + tasks.nextDueDate.isBiggerOrEqualValue(startOfDay) & + tasks.nextDueDate.isSmallerThanValue(endOfDay) & + tasks.roomId.equals(roomId), + ); + query.orderBy([OrderingTerm.asc(tasks.name)]); + + return query.watch().map((rows) { + return rows.map((row) { + final task = row.readTable(tasks); + final room = row.readTable(rooms); + return TaskWithRoom(task: task, roomName: room.name, roomId: room.id); + }).toList(); + }); + } + /// Watch tasks whose [nextDueDate] is strictly before [referenceDate]. /// /// Returns tasks sorted by [nextDueDate] ascending (oldest first). @@ -84,4 +111,50 @@ class CalendarDao extends DatabaseAccessor }).toList(); }); } + + /// Watch overdue tasks (before [referenceDate]) within a specific [roomId]. + /// + /// Same as [watchOverdueTasks] but filtered to a single room. + Stream> watchOverdueTasksInRoom( + DateTime referenceDate, int roomId) { + final startOfReferenceDay = DateTime( + referenceDate.year, + referenceDate.month, + referenceDate.day, + ); + + final query = select(tasks).join([ + innerJoin(rooms, rooms.id.equalsExp(tasks.roomId)), + ]); + query.where( + tasks.nextDueDate.isSmallerThanValue(startOfReferenceDay) & + tasks.roomId.equals(roomId), + ); + query.orderBy([OrderingTerm.asc(tasks.nextDueDate)]); + + return query.watch().map((rows) { + return rows.map((row) { + final task = row.readTable(tasks); + final room = row.readTable(rooms); + return TaskWithRoom( + task: task, + roomName: room.name, + roomId: room.id, + ); + }).toList(); + }); + } + + /// Total task count within a specific room. + /// + /// Used to distinguish first-run empty state from celebration state + /// in the room calendar view. + Future getTaskCountInRoom(int roomId) async { + final countExp = tasks.id.count(); + final query = selectOnly(tasks) + ..addColumns([countExp]) + ..where(tasks.roomId.equals(roomId)); + final result = await query.getSingle(); + return result.read(countExp) ?? 0; + } } diff --git a/lib/features/home/presentation/calendar_day_list.dart b/lib/features/home/presentation/calendar_day_list.dart index b766761..134cf9b 100644 --- a/lib/features/home/presentation/calendar_day_list.dart +++ b/lib/features/home/presentation/calendar_day_list.dart @@ -14,7 +14,8 @@ const _overdueColor = Color(0xFFE07A5F); /// Shows the task list for the selected calendar day. /// -/// Watches [calendarDayProvider] and renders one of several states: +/// Watches [calendarDayProvider] (or [roomCalendarDayProvider] when [roomId] +/// is provided) 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 @@ -22,7 +23,10 @@ const _overdueColor = Color(0xFFE07A5F); /// - 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}); + const CalendarDayList({super.key, this.roomId}); + + /// When non-null, filters tasks to this room only. + final int? roomId; @override ConsumerState createState() => _CalendarDayListState(); @@ -43,7 +47,9 @@ class _CalendarDayListState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); - final dayState = ref.watch(calendarDayProvider); + final dayState = widget.roomId != null + ? ref.watch(roomCalendarDayProvider(widget.roomId!)) + : ref.watch(calendarDayProvider); return dayState.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -96,6 +102,46 @@ class _CalendarDayListState extends ConsumerState { AppLocalizations l10n, ThemeData theme, ) { + // Room-scoped: prompt to create a task in this room. + if (widget.roomId != null) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.task_alt, + size: 80, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(height: 24), + Text( + l10n.taskEmptyTitle, + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.taskEmptyMessage, + 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/${widget.roomId}/tasks/new'), + child: Text(l10n.taskEmptyAction), + ), + ], + ), + ), + ); + } + + // Home-screen: prompt to create a room. return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), @@ -194,20 +240,36 @@ class _CalendarDayListState extends ConsumerState { AppLocalizations l10n, ThemeData theme, ) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final isFuture = state.selectedDate.isAfter(today); + final showRoomTag = widget.roomId == null; + final items = []; // Overdue section (today only, when overdue tasks exist). + // Overdue tasks are always completable (they're past due, only shown on today). if (state.overdueTasks.isNotEmpty) { items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme, color: _overdueColor)); for (final tw in state.overdueTasks) { - items.add(_buildAnimatedTaskRow(tw, isOverdue: true)); + items.add(_buildAnimatedTaskRow( + tw, + isOverdue: true, + showRoomTag: showRoomTag, + canComplete: true, + )); } } // Day tasks section. for (final tw in state.dayTasks) { - items.add(_buildAnimatedTaskRow(tw, isOverdue: false)); + items.add(_buildAnimatedTaskRow( + tw, + isOverdue: false, + showRoomTag: showRoomTag, + canComplete: !isFuture, + )); } return ListView(children: items); @@ -227,7 +289,12 @@ class _CalendarDayListState extends ConsumerState { ); } - Widget _buildAnimatedTaskRow(TaskWithRoom tw, {required bool isOverdue}) { + Widget _buildAnimatedTaskRow( + TaskWithRoom tw, { + required bool isOverdue, + required bool showRoomTag, + required bool canComplete, + }) { final isCompleting = _completingTaskIds.contains(tw.task.id); if (isCompleting) { @@ -235,6 +302,7 @@ class _CalendarDayListState extends ConsumerState { key: ValueKey('completing-${tw.task.id}'), taskWithRoom: tw, isOverdue: isOverdue, + showRoomTag: showRoomTag, ); } @@ -242,6 +310,8 @@ class _CalendarDayListState extends ConsumerState { key: ValueKey('task-${tw.task.id}'), taskWithRoom: tw, isOverdue: isOverdue, + showRoomTag: showRoomTag, + canComplete: canComplete, onCompleted: () => _onTaskCompleted(tw.task.id), ); } @@ -253,10 +323,12 @@ class _CompletingTaskRow extends StatefulWidget { super.key, required this.taskWithRoom, required this.isOverdue, + required this.showRoomTag, }); final TaskWithRoom taskWithRoom; final bool isOverdue; + final bool showRoomTag; @override State<_CompletingTaskRow> createState() => _CompletingTaskRowState(); @@ -302,6 +374,7 @@ class _CompletingTaskRowState extends State<_CompletingTaskRow> child: CalendarTaskRow( taskWithRoom: widget.taskWithRoom, isOverdue: widget.isOverdue, + showRoomTag: widget.showRoomTag, 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 cf2fed2..d374f78 100644 --- a/lib/features/home/presentation/calendar_providers.dart +++ b/lib/features/home/presentation/calendar_providers.dart @@ -104,3 +104,43 @@ final calendarDayProvider = ); }); }); + +/// Room-scoped calendar day state: tasks for the selected date within a room. +/// +/// Mirrors [calendarDayProvider] but filters by [roomId]. +/// Uses the shared [selectedDateProvider] so date selection is consistent +/// across HomeScreen and room views. +final roomCalendarDayProvider = + StreamProvider.autoDispose.family((ref, roomId) { + final db = ref.watch(appDatabaseProvider); + final selectedDate = ref.watch(selectedDateProvider); + final sortOption = ref.watch(sortPreferenceProvider); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final isToday = selectedDate == today; + + final dayTasksStream = + db.calendarDao.watchTasksForDateInRoom(selectedDate, roomId); + + return dayTasksStream.asyncMap((dayTasks) async { + final List overdueTasks; + + if (isToday) { + overdueTasks = await db.calendarDao + .watchOverdueTasksInRoom(selectedDate, roomId) + .first; + } else { + overdueTasks = const []; + } + + final totalTaskCount = await db.calendarDao.getTaskCountInRoom(roomId); + + return CalendarDayState( + selectedDate: selectedDate, + dayTasks: _sortTasks(dayTasks, sortOption), + overdueTasks: overdueTasks, + totalTaskCount: totalTaskCount, + ); + }); +}); diff --git a/lib/features/home/presentation/calendar_task_row.dart b/lib/features/home/presentation/calendar_task_row.dart index f80725b..8cb26de 100644 --- a/lib/features/home/presentation/calendar_task_row.dart +++ b/lib/features/home/presentation/calendar_task_row.dart @@ -20,6 +20,8 @@ class CalendarTaskRow extends StatelessWidget { required this.taskWithRoom, required this.onCompleted, this.isOverdue = false, + this.showRoomTag = true, + this.canComplete = true, }); final TaskWithRoom taskWithRoom; @@ -30,6 +32,12 @@ class CalendarTaskRow extends StatelessWidget { /// When true, task name is rendered in coral color. final bool isOverdue; + /// When false, the room tag subtitle is hidden (e.g. in room-scoped view). + final bool showRoomTag; + + /// When false, the checkbox is disabled (e.g. for future tasks). + final bool canComplete; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -41,7 +49,7 @@ class CalendarTaskRow extends StatelessWidget { ), leading: Checkbox( value: false, - onChanged: (_) => onCompleted(), + onChanged: canComplete ? (_) => onCompleted() : null, ), title: Text( task.name, @@ -51,22 +59,25 @@ class CalendarTaskRow extends StatelessWidget { 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, - ), - ), - ), - ), + subtitle: showRoomTag + ? 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, + ), + ), + ), + ) + : null, ); } } diff --git a/lib/features/tasks/presentation/task_list_screen.dart b/lib/features/tasks/presentation/task_list_screen.dart index 5081955..3db2a89 100644 --- a/lib/features/tasks/presentation/task_list_screen.dart +++ b/lib/features/tasks/presentation/task_list_screen.dart @@ -4,34 +4,46 @@ import 'package:go_router/go_router.dart'; import 'package:household_keeper/core/database/database.dart'; import 'package:household_keeper/core/providers/database_provider.dart'; +import 'package:household_keeper/features/home/presentation/calendar_day_list.dart'; +import 'package:household_keeper/features/home/presentation/calendar_providers.dart'; +import 'package:household_keeper/features/home/presentation/calendar_strip.dart'; import 'package:household_keeper/features/tasks/presentation/sort_dropdown.dart'; -import 'package:household_keeper/features/tasks/presentation/task_providers.dart'; -import 'package:household_keeper/features/tasks/presentation/task_row.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; -/// Screen displaying all tasks within a room, sorted by due date. +/// Screen displaying tasks within a room filtered by the selected calendar day. /// -/// Shows an empty state when no tasks exist, with a button to create one. -/// FAB always visible for quick task creation. +/// Shows a horizontal calendar strip at the top (same as HomeScreen) and +/// a date-filtered task list below. FAB always visible for quick task creation. /// AppBar shows room name with edit and delete actions. -class TaskListScreen extends ConsumerWidget { +class TaskListScreen extends ConsumerStatefulWidget { const TaskListScreen({super.key, required this.roomId}); final int roomId; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _TaskListScreenState(); +} + +class _TaskListScreenState extends ConsumerState { + late final CalendarStripController _stripController = + CalendarStripController(); + + /// Whether to show the floating "Heute" button. + /// True when the user has scrolled away from today's card. + bool _showTodayButton = false; + + @override + Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final asyncTasks = ref.watch(tasksInRoomProvider(roomId)); return Scaffold( appBar: AppBar( - title: _RoomTitle(roomId: roomId), + title: _RoomTitle(roomId: widget.roomId), actions: [ const SortDropdown(), IconButton( icon: const Icon(Icons.edit), - onPressed: () => context.go('/rooms/$roomId/edit'), + onPressed: () => context.go('/rooms/${widget.roomId}/edit'), ), IconButton( icon: const Icon(Icons.delete), @@ -39,33 +51,43 @@ class TaskListScreen extends ConsumerWidget { ), ], ), - body: asyncTasks.when( - data: (tasks) { - if (tasks.isEmpty) { - return _EmptyState(l10n: l10n, roomId: roomId); - } - return _TaskListView( - tasks: tasks, - l10n: l10n, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, + body: Stack( + children: [ + Column( children: [ - Text('Fehler: $error'), - const SizedBox(height: 16), - FilledButton.tonal( - onPressed: () => ref.invalidate(tasksInRoomProvider(roomId)), - child: const Text('Erneut versuchen'), + CalendarStrip( + controller: _stripController, + onTodayVisibilityChanged: (visible) { + setState(() => _showTodayButton = !visible); + }, ), + Expanded(child: CalendarDayList(roomId: widget.roomId)), ], ), - ), + if (_showTodayButton) + Positioned( + bottom: 80, // Above the FAB + left: 0, + right: 0, + child: Center( + child: FloatingActionButton.extended( + onPressed: () { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + ref + .read(selectedDateProvider.notifier) + .selectDate(today); + _stripController.scrollToToday(); + }, + icon: const Icon(Icons.today), + label: Text(l10n.calendarTodayButton), + ), + ), + ), + ], ), floatingActionButton: FloatingActionButton( - onPressed: () => context.go('/rooms/$roomId/tasks/new'), + onPressed: () => context.go('/rooms/${widget.roomId}/tasks/new'), child: const Icon(Icons.add), ), ); @@ -90,7 +112,7 @@ class TaskListScreen extends ConsumerWidget { onPressed: () { Navigator.pop(ctx); final db = ref.read(appDatabaseProvider); - db.roomsDao.deleteRoom(roomId); + db.roomsDao.deleteRoom(widget.roomId); // Navigate back to rooms list context.go('/rooms'); }, @@ -126,105 +148,3 @@ class _RoomTitle extends ConsumerWidget { ); } } - -/// Empty state shown when the room has no tasks. -class _EmptyState extends StatelessWidget { - const _EmptyState({required this.l10n, required this.roomId}); - - final AppLocalizations l10n; - final int roomId; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.task_alt, - size: 80, - color: theme.colorScheme.onSurface.withValues(alpha: 0.4), - ), - const SizedBox(height: 24), - Text( - l10n.taskEmptyTitle, - style: theme.textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - l10n.taskEmptyMessage, - 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/$roomId/tasks/new'), - child: Text(l10n.taskEmptyAction), - ), - ], - ), - ), - ); - } -} - -/// List view of task rows with long-press delete support. -class _TaskListView extends ConsumerWidget { - const _TaskListView({required this.tasks, required this.l10n}); - - final List tasks; - final AppLocalizations l10n; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ListView.builder( - itemCount: tasks.length, - itemBuilder: (context, index) { - final task = tasks[index]; - return TaskRow( - key: ValueKey(task.id), - task: task, - onDelete: () => _showDeleteConfirmation(context, ref, task), - ); - }, - ); - } - - void _showDeleteConfirmation( - BuildContext context, - WidgetRef ref, - Task task, - ) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.taskDeleteConfirmTitle), - content: Text(l10n.taskDeleteConfirmMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () { - Navigator.pop(ctx); - ref.read(taskActionsProvider.notifier).deleteTask(task.id); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.error, - foregroundColor: Theme.of(context).colorScheme.onError, - ), - child: Text(l10n.taskDeleteConfirmAction), - ), - ], - ), - ); - } -} diff --git a/lib/features/tasks/presentation/task_row.dart b/lib/features/tasks/presentation/task_row.dart index 4e3665b..e375d12 100644 --- a/lib/features/tasks/presentation/task_row.dart +++ b/lib/features/tasks/presentation/task_row.dart @@ -42,6 +42,7 @@ class TaskRow extends ConsumerWidget { task.nextDueDate.day, ); final isOverdue = dueDate.isBefore(today); + final isFuture = dueDate.isAfter(today); // Format relative due date in German final relativeDateText = formatRelativeDate(task.nextDueDate, now); @@ -56,10 +57,12 @@ class TaskRow extends ConsumerWidget { return ListTile( leading: Checkbox( value: false, // Always unchecked -- completion is immediate + reschedule - onChanged: (_) { - // Mark done immediately (optimistic UI, no undo per user decision) - ref.read(taskActionsProvider.notifier).completeTask(task.id); - }, + onChanged: isFuture + ? null // Future tasks cannot be completed yet + : (_) { + // Mark done immediately (optimistic UI, no undo per user decision) + ref.read(taskActionsProvider.notifier).completeTask(task.id); + }, ), title: Text( task.name, diff --git a/test/features/home/data/calendar_dao_test.dart b/test/features/home/data/calendar_dao_test.dart index d830e99..326611e 100644 --- a/test/features/home/data/calendar_dao_test.dart +++ b/test/features/home/data/calendar_dao_test.dart @@ -283,4 +283,267 @@ void main() { expect(result.first.roomId, room2Id); }); }); + + group('CalendarDao.watchTasksForDateInRoom', () { + test('returns empty list when no tasks exist in room', () async { + final result = await db.calendarDao + .watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result, isEmpty); + }); + + test('returns only tasks due on the queried date in the specified room', + () async { + // Task due on March 16 in room1 + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 16, 9, 30), + )); + // Task due on March 15 in room1 (should NOT appear) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Staubsaugen', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 15), + )); + // Task due on March 17 in room1 (should NOT appear) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Fenster putzen', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.high, + nextDueDate: DateTime(2026, 3, 17), + )); + + final result = await db.calendarDao + .watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result.length, 1); + expect(result.first.task.name, 'Abspuelen'); + }); + + test('does not return tasks from other rooms on the same date', () async { + // Task due on March 16 in room1 + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Kueche Aufgabe', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 16), + )); + // Task due on March 16 in room2 (should NOT appear when querying room1) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room2Id, + name: 'Bad Aufgabe', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 16), + )); + + final result = await db.calendarDao + .watchTasksForDateInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result.length, 1); + expect(result.first.task.name, 'Kueche Aufgabe'); + expect(result.first.roomId, room1Id); + }); + + test('returns tasks sorted alphabetically by name', () async { + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Zitrone putzen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 18), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 18, 10), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Moppen', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 18, 8), + )); + + final result = await db.calendarDao + .watchTasksForDateInRoom(DateTime(2026, 3, 18), room1Id) + .first; + expect(result.length, 3); + expect(result[0].task.name, 'Abspuelen'); + expect(result[1].task.name, 'Moppen'); + expect(result[2].task.name, 'Zitrone putzen'); + }); + }); + + group('CalendarDao.watchOverdueTasksInRoom', () { + test('returns empty list when no overdue tasks in room', () async { + final result = await db.calendarDao + .watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result, isEmpty); + }); + + test('returns only overdue tasks in the specified room', () async { + // Task due March 15 in room1 — overdue relative to March 16 + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Ueberfaelliges Task', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + )); + // Task due March 10 in room1 — also overdue + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Sehr altes Task', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 10), + )); + // Task due on March 16 in room1 — NOT overdue (should NOT appear) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Heutiges Task', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 16), + )); + + final result = await db.calendarDao + .watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result.length, 2); + }); + + test('does not return overdue tasks from other rooms', () async { + // Overdue task in room1 + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Kueche Overdue', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 14), + )); + // Overdue task in room2 (should NOT appear when querying room1) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room2Id, + name: 'Bad Overdue', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 14), + )); + + final result = await db.calendarDao + .watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result.length, 1); + expect(result.first.task.name, 'Kueche Overdue'); + expect(result.first.roomId, room1Id); + }); + + test('returns overdue tasks sorted by nextDueDate ascending', () async { + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Neues Overdue', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Altes Overdue', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 1), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Mittleres Overdue', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 10), + )); + + final result = await db.calendarDao + .watchOverdueTasksInRoom(DateTime(2026, 3, 16), room1Id) + .first; + expect(result.length, 3); + expect(result[0].task.name, 'Altes Overdue'); + expect(result[1].task.name, 'Mittleres Overdue'); + expect(result[2].task.name, 'Neues Overdue'); + }); + }); + + group('CalendarDao.getTaskCountInRoom', () { + test('returns 0 when room has no tasks', () async { + final count = await db.calendarDao.getTaskCountInRoom(room1Id); + expect(count, 0); + }); + + test('returns correct count for room', () async { + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Aufgabe 1', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 16), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Aufgabe 2', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 20), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Aufgabe 3', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.high, + nextDueDate: DateTime(2026, 3, 25), + )); + + final count = await db.calendarDao.getTaskCountInRoom(room1Id); + expect(count, 3); + }); + + test('does not count tasks from other rooms', () async { + // Two tasks in room1 + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Kueche Aufgabe 1', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 16), + )); + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room1Id, + name: 'Kueche Aufgabe 2', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 20), + )); + // One task in room2 (should NOT be counted for room1) + await db.tasksDao.insertTask(TasksCompanion.insert( + roomId: room2Id, + name: 'Bad Aufgabe', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.high, + nextDueDate: DateTime(2026, 3, 18), + )); + + final count = await db.calendarDao.getTaskCountInRoom(room1Id); + expect(count, 2); + }); + }); } diff --git a/test/features/tasks/presentation/task_list_screen_test.dart b/test/features/tasks/presentation/task_list_screen_test.dart new file mode 100644 index 0000000..c141e58 --- /dev/null +++ b/test/features/tasks/presentation/task_list_screen_test.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:household_keeper/core/database/database.dart'; +import 'package:household_keeper/features/home/domain/calendar_models.dart'; +import 'package:household_keeper/features/home/domain/daily_plan_models.dart'; +import 'package:household_keeper/features/home/presentation/calendar_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/features/tasks/domain/task_sort_option.dart'; +import 'package:household_keeper/features/tasks/presentation/sort_preference_notifier.dart'; +import 'package:household_keeper/features/tasks/presentation/task_list_screen.dart'; +import 'package:household_keeper/l10n/app_localizations.dart'; + +/// Helper to create a test [Task] with sensible defaults. +Task _makeTask({ + int id = 1, + int roomId = 1, + String name = 'Test Task', + required DateTime nextDueDate, +}) { + return Task( + id: id, + roomId: roomId, + name: name, + intervalType: IntervalType.weekly, + intervalDays: 7, + effortLevel: EffortLevel.medium, + nextDueDate: nextDueDate, + createdAt: DateTime(2026, 1, 1), + ); +} + +/// Helper to create a [TaskWithRoom]. +TaskWithRoom _makeTaskWithRoom({ + int id = 1, + int roomId = 1, + String taskName = 'Test Task', + String roomName = 'Kueche', + required DateTime nextDueDate, +}) { + return TaskWithRoom( + task: _makeTask( + id: id, + roomId: roomId, + name: taskName, + nextDueDate: nextDueDate, + ), + roomName: roomName, + roomId: roomId, + ); +} + +/// Build the app with [roomCalendarDayProvider] overridden to the given state. +/// +/// Uses [UncontrolledProviderScope] with a [ProviderContainer] to avoid +/// the riverpod_lint scoped_providers_should_specify_dependencies warning. +/// +/// Renders [TaskListScreen] directly with roomId 1, wrapped in a [MaterialApp] +/// to avoid router complexity. The _RoomTitle widget will show '...' because +/// appDatabaseProvider is not overridden — this is acceptable for these tests. +Widget _buildApp(CalendarDayState dayState) { + final container = ProviderContainer(overrides: [ + roomCalendarDayProvider.overrideWith( + (ref, roomId) => Stream.value(dayState), + ), + selectedDateProvider.overrideWith(SelectedDateNotifier.new), + sortPreferenceProvider.overrideWith(SortPreferenceNotifier.new), + ]); + + return UncontrolledProviderScope( + container: container, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: [Locale('de')], + locale: Locale('de'), + home: TaskListScreen(roomId: 1), + ), + ); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + + group('TaskListScreen empty states', () { + testWidgets('shows no-tasks empty state when no tasks exist in room', + (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)); + + // Should show "Noch keine Aufgaben" (taskEmptyTitle) + expect(find.text('Noch keine Aufgaben'), findsOneWidget); + // Should show action button to create a task + expect(find.text('Aufgabe erstellen'), findsOneWidget); + }); + + testWidgets('shows celebration state when tasks exist but today is clear', + (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: const [], + overdueTasks: const [], + totalTaskCount: 5, // tasks exist elsewhere + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Should show celebration state + expect(find.byIcon(Icons.celebration_outlined), 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('TaskListScreen normal state', () { + testWidgets('shows overdue section when overdue tasks exist (today)', + (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: [ + _makeTaskWithRoom( + id: 2, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + overdueTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Boden wischen', + roomName: 'Kueche', + nextDueDate: yesterday, + ), + ], + totalTaskCount: 2, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Should show overdue section header + expect(find.text('\u00dcberf\u00e4llig'), findsOneWidget); + // Should show both tasks + expect(find.text('Boden wischen'), findsOneWidget); + expect(find.text('Staubsaugen'), findsOneWidget); + }); + + testWidgets('does not show overdue section for non-today date', + (tester) async { + final tomorrow = today.add(const Duration(days: 1)); + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: tomorrow, + dayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: tomorrow, + ), + ], + overdueTasks: const [], // No overdue for non-today + totalTaskCount: 1, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Should NOT show overdue section header + expect(find.text('\u00dcberf\u00e4llig'), findsNothing); + // Should show day task + expect(find.text('Staubsaugen'), findsOneWidget); + }); + + testWidgets('tasks have checkboxes', (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Wohnzimmer', + nextDueDate: today, + ), + ], + overdueTasks: const [], + totalTaskCount: 1, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Task should have a checkbox + expect(find.byType(Checkbox), findsOneWidget); + }); + + testWidgets('room tag is NOT shown on task rows', (tester) async { + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: today, + dayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Staubsaugen', + roomName: 'Kueche', + nextDueDate: today, + ), + ], + overdueTasks: const [], + totalTaskCount: 1, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Task name should be visible + expect(find.text('Staubsaugen'), findsOneWidget); + // Room tag text should NOT appear (showRoomTag is false in room context) + expect(find.text('Kueche'), findsNothing); + }); + }); + + group('TaskListScreen future task restriction', () { + testWidgets('checkboxes are disabled for future tasks', (tester) async { + final futureDate = today.add(const Duration(days: 3)); + await tester.pumpWidget(_buildApp(CalendarDayState( + selectedDate: futureDate, + dayTasks: [ + _makeTaskWithRoom( + id: 1, + taskName: 'Fenster putzen', + roomName: 'Kueche', + nextDueDate: futureDate, + ), + ], + overdueTasks: const [], + totalTaskCount: 1, + ))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Task should be visible + expect(find.text('Fenster putzen'), findsOneWidget); + + // Checkbox should exist but be disabled (onChanged is null) + final checkbox = tester.widget(find.byType(Checkbox)); + expect(checkbox.onChanged, isNull); + }); + }); + + group('TaskListScreen AppBar', () { + testWidgets('shows sort dropdown in AppBar', (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)); + + // SortDropdown wraps a PopupMenuButton + expect( + find.byType(PopupMenuButton), + findsOneWidget, + ); + }); + + testWidgets('shows edit and delete icon buttons', (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)); + + // Edit icon button + expect(find.byIcon(Icons.edit), findsOneWidget); + // Delete icon button + expect(find.byIcon(Icons.delete), 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); + }); + }); +}