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:
127
lib/features/rooms/data/rooms_dao.dart
Normal file
127
lib/features/rooms/data/rooms_dao.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
25
lib/features/rooms/data/rooms_dao.g.dart
Normal file
25
lib/features/rooms/data/rooms_dao.g.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
40
lib/features/rooms/domain/room_icons.dart
Normal file
40
lib/features/rooms/domain/room_icons.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Curated list of ~25 household Material Icons for the room icon picker.
|
||||
const List<({String name, IconData icon})> curatedRoomIcons = [
|
||||
(name: 'kitchen', icon: Icons.kitchen),
|
||||
(name: 'bathtub', icon: Icons.bathtub),
|
||||
(name: 'bed', icon: Icons.bed),
|
||||
(name: 'living', icon: Icons.living),
|
||||
(name: 'weekend', icon: Icons.weekend),
|
||||
(name: 'door_front', icon: Icons.door_front_door),
|
||||
(name: 'desk', icon: Icons.desk),
|
||||
(name: 'garage', icon: Icons.garage),
|
||||
(name: 'balcony', icon: Icons.balcony),
|
||||
(name: 'local_laundry', icon: Icons.local_laundry_service),
|
||||
(name: 'stairs', icon: Icons.stairs),
|
||||
(name: 'child_care', icon: Icons.child_care),
|
||||
(name: 'single_bed', icon: Icons.single_bed),
|
||||
(name: 'dining', icon: Icons.dining),
|
||||
(name: 'yard', icon: Icons.yard),
|
||||
(name: 'grass', icon: Icons.grass),
|
||||
(name: 'home', icon: Icons.home),
|
||||
(name: 'storage', icon: Icons.inventory_2),
|
||||
(name: 'window', icon: Icons.window),
|
||||
(name: 'cleaning', icon: Icons.cleaning_services),
|
||||
(name: 'iron', icon: Icons.iron),
|
||||
(name: 'microwave', icon: Icons.microwave),
|
||||
(name: 'shower', icon: Icons.shower),
|
||||
(name: 'chair', icon: Icons.chair),
|
||||
(name: 'door_sliding', icon: Icons.door_sliding),
|
||||
];
|
||||
|
||||
/// Map a stored icon name string back to its IconData.
|
||||
///
|
||||
/// Returns [Icons.home] as fallback if the name is not found.
|
||||
IconData mapIconName(String name) {
|
||||
for (final entry in curatedRoomIcons) {
|
||||
if (entry.name == name) return entry.icon;
|
||||
}
|
||||
return Icons.home;
|
||||
}
|
||||
Reference in New Issue
Block a user