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
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:
@@ -55,6 +55,33 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
return result.read(countExp) ?? 0;
|
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].
|
/// Watch tasks whose [nextDueDate] is strictly before [referenceDate].
|
||||||
///
|
///
|
||||||
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
/// Returns tasks sorted by [nextDueDate] ascending (oldest first).
|
||||||
@@ -84,4 +111,50 @@ class CalendarDao extends DatabaseAccessor<AppDatabase>
|
|||||||
}).toList();
|
}).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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const _overdueColor = Color(0xFFE07A5F);
|
|||||||
|
|
||||||
/// Shows the task list for the selected calendar day.
|
/// 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
|
/// - Loading spinner while data loads
|
||||||
/// - Error text on failure
|
/// - Error text on failure
|
||||||
/// - First-run empty state (no rooms/tasks at all) — prompts to create a room
|
/// - 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)
|
/// - Celebration state (today is selected and all tasks are done)
|
||||||
/// - Has-tasks state with optional overdue section (today only) and checkboxes
|
/// - Has-tasks state with optional overdue section (today only) and checkboxes
|
||||||
class CalendarDayList extends ConsumerStatefulWidget {
|
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
|
@override
|
||||||
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
|
ConsumerState<CalendarDayList> createState() => _CalendarDayListState();
|
||||||
@@ -43,7 +47,9 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.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(
|
return dayState.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
@@ -96,6 +102,46 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
ThemeData theme,
|
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(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
@@ -194,20 +240,36 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
ThemeData theme,
|
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>[];
|
final items = <Widget>[];
|
||||||
|
|
||||||
// Overdue section (today only, when overdue tasks exist).
|
// 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) {
|
if (state.overdueTasks.isNotEmpty) {
|
||||||
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
|
items.add(_buildSectionHeader(l10n.dailyPlanSectionOverdue, theme,
|
||||||
color: _overdueColor));
|
color: _overdueColor));
|
||||||
for (final tw in state.overdueTasks) {
|
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.
|
// Day tasks section.
|
||||||
for (final tw in state.dayTasks) {
|
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);
|
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);
|
final isCompleting = _completingTaskIds.contains(tw.task.id);
|
||||||
|
|
||||||
if (isCompleting) {
|
if (isCompleting) {
|
||||||
@@ -235,6 +302,7 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|||||||
key: ValueKey('completing-${tw.task.id}'),
|
key: ValueKey('completing-${tw.task.id}'),
|
||||||
taskWithRoom: tw,
|
taskWithRoom: tw,
|
||||||
isOverdue: isOverdue,
|
isOverdue: isOverdue,
|
||||||
|
showRoomTag: showRoomTag,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +310,8 @@ class _CalendarDayListState extends ConsumerState<CalendarDayList> {
|
|||||||
key: ValueKey('task-${tw.task.id}'),
|
key: ValueKey('task-${tw.task.id}'),
|
||||||
taskWithRoom: tw,
|
taskWithRoom: tw,
|
||||||
isOverdue: isOverdue,
|
isOverdue: isOverdue,
|
||||||
|
showRoomTag: showRoomTag,
|
||||||
|
canComplete: canComplete,
|
||||||
onCompleted: () => _onTaskCompleted(tw.task.id),
|
onCompleted: () => _onTaskCompleted(tw.task.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,10 +323,12 @@ class _CompletingTaskRow extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.taskWithRoom,
|
required this.taskWithRoom,
|
||||||
required this.isOverdue,
|
required this.isOverdue,
|
||||||
|
required this.showRoomTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TaskWithRoom taskWithRoom;
|
final TaskWithRoom taskWithRoom;
|
||||||
final bool isOverdue;
|
final bool isOverdue;
|
||||||
|
final bool showRoomTag;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
State<_CompletingTaskRow> createState() => _CompletingTaskRowState();
|
||||||
@@ -302,6 +374,7 @@ class _CompletingTaskRowState extends State<_CompletingTaskRow>
|
|||||||
child: CalendarTaskRow(
|
child: CalendarTaskRow(
|
||||||
taskWithRoom: widget.taskWithRoom,
|
taskWithRoom: widget.taskWithRoom,
|
||||||
isOverdue: widget.isOverdue,
|
isOverdue: widget.isOverdue,
|
||||||
|
showRoomTag: widget.showRoomTag,
|
||||||
onCompleted: () {}, // Already completing — ignore repeat taps.
|
onCompleted: () {}, // Already completing — ignore repeat taps.
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class CalendarTaskRow extends StatelessWidget {
|
|||||||
required this.taskWithRoom,
|
required this.taskWithRoom,
|
||||||
required this.onCompleted,
|
required this.onCompleted,
|
||||||
this.isOverdue = false,
|
this.isOverdue = false,
|
||||||
|
this.showRoomTag = true,
|
||||||
|
this.canComplete = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TaskWithRoom taskWithRoom;
|
final TaskWithRoom taskWithRoom;
|
||||||
@@ -30,6 +32,12 @@ class CalendarTaskRow extends StatelessWidget {
|
|||||||
/// When true, task name is rendered in coral color.
|
/// When true, task name is rendered in coral color.
|
||||||
final bool isOverdue;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -41,7 +49,7 @@ class CalendarTaskRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
leading: Checkbox(
|
leading: Checkbox(
|
||||||
value: false,
|
value: false,
|
||||||
onChanged: (_) => onCompleted(),
|
onChanged: canComplete ? (_) => onCompleted() : null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
task.name,
|
task.name,
|
||||||
@@ -51,22 +59,25 @@ class CalendarTaskRow extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: GestureDetector(
|
subtitle: showRoomTag
|
||||||
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
? GestureDetector(
|
||||||
child: Container(
|
onTap: () => context.go('/rooms/${taskWithRoom.roomId}'),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
padding:
|
||||||
color: theme.colorScheme.secondaryContainer,
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
decoration: BoxDecoration(
|
||||||
),
|
color: theme.colorScheme.secondaryContainer,
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(4),
|
||||||
taskWithRoom.roomName,
|
),
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
child: Text(
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
taskWithRoom.roomName,
|
||||||
),
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
),
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,34 +4,46 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import 'package:household_keeper/core/database/database.dart';
|
import 'package:household_keeper/core/database/database.dart';
|
||||||
import 'package:household_keeper/core/providers/database_provider.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/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';
|
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.
|
/// Shows a horizontal calendar strip at the top (same as HomeScreen) and
|
||||||
/// FAB always visible for quick task creation.
|
/// a date-filtered task list below. FAB always visible for quick task creation.
|
||||||
/// AppBar shows room name with edit and delete actions.
|
/// AppBar shows room name with edit and delete actions.
|
||||||
class TaskListScreen extends ConsumerWidget {
|
class TaskListScreen extends ConsumerStatefulWidget {
|
||||||
const TaskListScreen({super.key, required this.roomId});
|
const TaskListScreen({super.key, required this.roomId});
|
||||||
|
|
||||||
final int roomId;
|
final int roomId;
|
||||||
|
|
||||||
@override
|
@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 l10n = AppLocalizations.of(context);
|
||||||
final asyncTasks = ref.watch(tasksInRoomProvider(roomId));
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: _RoomTitle(roomId: roomId),
|
title: _RoomTitle(roomId: widget.roomId),
|
||||||
actions: [
|
actions: [
|
||||||
const SortDropdown(),
|
const SortDropdown(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () => context.go('/rooms/$roomId/edit'),
|
onPressed: () => context.go('/rooms/${widget.roomId}/edit'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
@@ -39,33 +51,43 @@ class TaskListScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: asyncTasks.when(
|
body: Stack(
|
||||||
data: (tasks) {
|
children: [
|
||||||
if (tasks.isEmpty) {
|
Column(
|
||||||
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,
|
|
||||||
children: [
|
children: [
|
||||||
Text('Fehler: $error'),
|
CalendarStrip(
|
||||||
const SizedBox(height: 16),
|
controller: _stripController,
|
||||||
FilledButton.tonal(
|
onTodayVisibilityChanged: (visible) {
|
||||||
onPressed: () => ref.invalidate(tasksInRoomProvider(roomId)),
|
setState(() => _showTodayButton = !visible);
|
||||||
child: const Text('Erneut versuchen'),
|
},
|
||||||
),
|
),
|
||||||
|
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(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => context.go('/rooms/$roomId/tasks/new'),
|
onPressed: () => context.go('/rooms/${widget.roomId}/tasks/new'),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -90,7 +112,7 @@ class TaskListScreen extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
final db = ref.read(appDatabaseProvider);
|
final db = ref.read(appDatabaseProvider);
|
||||||
db.roomsDao.deleteRoom(roomId);
|
db.roomsDao.deleteRoom(widget.roomId);
|
||||||
// Navigate back to rooms list
|
// Navigate back to rooms list
|
||||||
context.go('/rooms');
|
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class TaskRow extends ConsumerWidget {
|
|||||||
task.nextDueDate.day,
|
task.nextDueDate.day,
|
||||||
);
|
);
|
||||||
final isOverdue = dueDate.isBefore(today);
|
final isOverdue = dueDate.isBefore(today);
|
||||||
|
final isFuture = dueDate.isAfter(today);
|
||||||
|
|
||||||
// Format relative due date in German
|
// Format relative due date in German
|
||||||
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
|
final relativeDateText = formatRelativeDate(task.nextDueDate, now);
|
||||||
@@ -56,10 +57,12 @@ class TaskRow extends ConsumerWidget {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Checkbox(
|
leading: Checkbox(
|
||||||
value: false, // Always unchecked -- completion is immediate + reschedule
|
value: false, // Always unchecked -- completion is immediate + reschedule
|
||||||
onChanged: (_) {
|
onChanged: isFuture
|
||||||
// Mark done immediately (optimistic UI, no undo per user decision)
|
? null // Future tasks cannot be completed yet
|
||||||
ref.read(taskActionsProvider.notifier).completeTask(task.id);
|
: (_) {
|
||||||
},
|
// Mark done immediately (optimistic UI, no undo per user decision)
|
||||||
|
ref.read(taskActionsProvider.notifier).completeTask(task.id);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
task.name,
|
task.name,
|
||||||
|
|||||||
@@ -283,4 +283,267 @@ void main() {
|
|||||||
expect(result.first.roomId, room2Id);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
325
test/features/tasks/presentation/task_list_screen_test.dart
Normal file
325
test/features/tasks/presentation/task_list_screen_test.dart
Normal file
@@ -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<Checkbox>(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<TaskSortOption>
|
||||||
|
expect(
|
||||||
|
find.byType(PopupMenuButton<TaskSortOption>),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user