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,127 @@
import 'package:drift/drift.dart';
import '../../../core/database/database.dart';
part 'rooms_dao.g.dart';
/// Stats for a room including task counts and cleanliness ratio.
class RoomWithStats {
final Room room;
final int totalTasks;
final int dueTasks;
final int overdueCount;
final double cleanlinessRatio;
const RoomWithStats({
required this.room,
required this.totalTasks,
required this.dueTasks,
required this.overdueCount,
required this.cleanlinessRatio,
});
}
@DriftAccessor(tables: [Rooms, Tasks, TaskCompletions])
class RoomsDao extends DatabaseAccessor<AppDatabase> with _$RoomsDaoMixin {
RoomsDao(super.attachedDatabase);
/// Watch all rooms ordered by sortOrder.
Stream<List<Room>> watchAllRooms() {
return (select(rooms)..orderBy([(r) => OrderingTerm.asc(r.sortOrder)]))
.watch();
}
/// Watch all rooms with computed task stats.
///
/// Cleanliness ratio = (totalTasks - overdueCount) / totalTasks.
/// 1.0 when no tasks are overdue, 0.0 when all are overdue.
/// Rooms with no tasks have ratio 1.0.
Stream<List<RoomWithStats>> watchRoomWithStats({DateTime? today}) {
final now = today ?? DateTime.now();
final todayDateOnly = DateTime(now.year, now.month, now.day);
return watchAllRooms().asyncMap((roomList) async {
final stats = <RoomWithStats>[];
for (final room in roomList) {
final taskList = await (select(tasks)
..where((t) => t.roomId.equals(room.id)))
.get();
final totalTasks = taskList.length;
var overdueCount = 0;
var dueTasks = 0;
for (final task in taskList) {
final dueDate = DateTime(
task.nextDueDate.year,
task.nextDueDate.month,
task.nextDueDate.day,
);
if (dueDate.isBefore(todayDateOnly) ||
dueDate.isAtSameMomentAs(todayDateOnly)) {
dueTasks++;
}
if (dueDate.isBefore(todayDateOnly)) {
overdueCount++;
}
}
final ratio =
totalTasks == 0 ? 1.0 : (totalTasks - overdueCount) / totalTasks;
stats.add(RoomWithStats(
room: room,
totalTasks: totalTasks,
dueTasks: dueTasks,
overdueCount: overdueCount,
cleanlinessRatio: ratio,
));
}
return stats;
});
}
/// Insert a new room. Returns the auto-generated id.
Future<int> insertRoom(RoomsCompanion room) => into(rooms).insert(room);
/// Update an existing room. Returns true if a row was updated.
Future<bool> updateRoom(Room room) => update(rooms).replace(room);
/// Delete a room and cascade to its tasks and completions.
Future<void> deleteRoom(int roomId) {
return transaction(() async {
// Get all task IDs for this room
final taskList = await (select(tasks)
..where((t) => t.roomId.equals(roomId)))
.get();
// Delete completions for each task
for (final task in taskList) {
await (delete(taskCompletions)
..where((c) => c.taskId.equals(task.id)))
.go();
}
// Delete tasks
await (delete(tasks)..where((t) => t.roomId.equals(roomId))).go();
// Delete room
await (delete(rooms)..where((r) => r.id.equals(roomId))).go();
});
}
/// Reorder rooms by updating sortOrder for each room in the list.
Future<void> reorderRooms(List<int> roomIds) {
return transaction(() async {
for (var i = 0; i < roomIds.length; i++) {
await (update(rooms)..where((r) => r.id.equals(roomIds[i])))
.write(RoomsCompanion(sortOrder: Value(i)));
}
});
}
/// Get a single room by its id.
Future<Room> getRoomById(int id) {
return (select(rooms)..where((r) => r.id.equals(id))).getSingle();
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rooms_dao.dart';
// ignore_for_file: type=lint
mixin _$RoomsDaoMixin on DatabaseAccessor<AppDatabase> {
$RoomsTable get rooms => attachedDatabase.rooms;
$TasksTable get tasks => attachedDatabase.tasks;
$TaskCompletionsTable get taskCompletions => attachedDatabase.taskCompletions;
RoomsDaoManager get managers => RoomsDaoManager(this);
}
class RoomsDaoManager {
final _$RoomsDaoMixin _db;
RoomsDaoManager(this._db);
$$RoomsTableTableManager get rooms =>
$$RoomsTableTableManager(_db.attachedDatabase, _db.rooms);
$$TasksTableTableManager get tasks =>
$$TasksTableTableManager(_db.attachedDatabase, _db.tasks);
$$TaskCompletionsTableTableManager get taskCompletions =>
$$TaskCompletionsTableTableManager(
_db.attachedDatabase,
_db.taskCompletions,
);
}