test(TaskListScreen): add integration tests for filtered and overdue task states
All checks were successful
Build and Release to F-Droid / build-and-deploy (push) Successful in 10m30s

- 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
This commit is contained in:
2026-03-16 23:35:17 +01:00
parent edce11dd78
commit d220dbe5ce
8 changed files with 869 additions and 161 deletions

View File

@@ -55,6 +55,33 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
return result.read(countExp) ?? 0;
}
/// Watch tasks due on [date] within a specific [roomId].
///
/// Same as [watchTasksForDate] but filtered to a single room.
Stream<List<TaskWithRoom>> 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<AppDatabase>
}).toList();
});
}
/// Watch overdue tasks (before [referenceDate]) within a specific [roomId].
///
/// Same as [watchOverdueTasks] but filtered to a single room.
Stream<List<TaskWithRoom>> 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<int> 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;
}
}

View File

@@ -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<CalendarDayList> createState() => _CalendarDayListState();
@@ -43,7 +47,9 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
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<CalendarDayList> {
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<CalendarDayList> {
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 = <Widget>[];
// 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<CalendarDayList> {
);
}
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<CalendarDayList> {
key: ValueKey('completing-${tw.task.id}'),
taskWithRoom: tw,
isOverdue: isOverdue,
showRoomTag: showRoomTag,
);
}
@@ -242,6 +310,8 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
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.
),
),

View File

@@ -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<CalendarDayState, int>((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<TaskWithRoom> 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,
);
});
});

View File

@@ -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,
);
}
}

View File

@@ -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<TaskListScreen> createState() => _TaskListScreenState();
}
class _TaskListScreenState extends ConsumerState<TaskListScreen> {
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<Task> 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),
),
],
),
);
}
}

View File

@@ -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,