feat(02-01): Drift tables, DAOs, scheduling utility, domain models with tests

- Add Rooms, Tasks, TaskCompletions Drift tables with schema v2 migration
- Create RoomsDao with CRUD, watchAll, watchWithStats, cascade delete, reorder
- Create TasksDao with CRUD, watchInRoom (sorted by due), completeTask, overdue detection
- Implement calculateNextDueDate and catchUpToPresent pure scheduling functions
- Define IntervalType enum (8 types), EffortLevel enum, FrequencyInterval model
- Add formatRelativeDate German formatter and curatedRoomIcons icon list
- Enable PRAGMA foreign_keys in beforeOpen migration strategy
- All 30 unit tests passing (17 scheduling + 6 rooms DAO + 7 tasks DAO)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:50:12 +01:00
parent 515304b432
commit d2e452655c
18 changed files with 4082 additions and 6 deletions

View File

@@ -0,0 +1,102 @@
import 'package:drift/drift.dart';
import '../../../core/database/database.dart';
import '../domain/scheduling.dart';
part 'tasks_dao.g.dart';
@DriftAccessor(tables: [Tasks, TaskCompletions])
class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
TasksDao(super.attachedDatabase);
/// Watch tasks in a room sorted by nextDueDate ascending.
Stream<List<Task>> watchTasksInRoom(int roomId) {
return (select(tasks)
..where((t) => t.roomId.equals(roomId))
..orderBy([(t) => OrderingTerm.asc(t.nextDueDate)]))
.watch();
}
/// Insert a new task. Returns the auto-generated id.
Future<int> insertTask(TasksCompanion task) => into(tasks).insert(task);
/// Update an existing task. Returns true if a row was updated.
Future<bool> updateTask(Task task) => update(tasks).replace(task);
/// Delete a task and its completions.
Future<void> deleteTask(int taskId) {
return transaction(() async {
await (delete(taskCompletions)..where((c) => c.taskId.equals(taskId)))
.go();
await (delete(tasks)..where((t) => t.id.equals(taskId))).go();
});
}
/// Mark a task as done: records completion and calculates next due date.
///
/// Uses scheduling utility for date calculation. Next due is calculated
/// from the original due date (not completion date) to keep rhythm stable.
/// If the calculated next due is in the past, catch-up advances to present.
///
/// [now] parameter allows injection of current time for testing.
Future<void> completeTask(int taskId, {DateTime? now}) {
return transaction(() async {
// 1. Get current task
final task =
await (select(tasks)..where((t) => t.id.equals(taskId))).getSingle();
final currentTime = now ?? DateTime.now();
// 2. Record completion
await into(taskCompletions).insert(TaskCompletionsCompanion.insert(
taskId: taskId,
completedAt: currentTime,
));
// 3. Calculate next due date (from original due date, not today)
var nextDue = calculateNextDueDate(
currentDueDate: task.nextDueDate,
intervalType: task.intervalType,
intervalDays: task.intervalDays,
anchorDay: task.anchorDay,
);
// 4. Catch up if next due is still in the past
final todayDateOnly = DateTime(
currentTime.year,
currentTime.month,
currentTime.day,
);
nextDue = catchUpToPresent(
nextDue: nextDue,
today: todayDateOnly,
intervalType: task.intervalType,
intervalDays: task.intervalDays,
anchorDay: task.anchorDay,
);
// 5. Update task with new due date
await (update(tasks)..where((t) => t.id.equals(taskId)))
.write(TasksCompanion(nextDueDate: Value(nextDue)));
});
}
/// Count overdue tasks in a room (nextDueDate before today).
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
final now = today ?? DateTime.now();
final todayDateOnly = DateTime(now.year, now.month, now.day);
final taskList = await (select(tasks)
..where((t) => t.roomId.equals(roomId)))
.get();
return taskList.where((task) {
final dueDate = DateTime(
task.nextDueDate.year,
task.nextDueDate.month,
task.nextDueDate.day,
);
return dueDate.isBefore(todayDateOnly);
}).length;
}
}