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:
102
lib/features/tasks/data/tasks_dao.dart
Normal file
102
lib/features/tasks/data/tasks_dao.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
25
lib/features/tasks/data/tasks_dao.g.dart
Normal file
25
lib/features/tasks/data/tasks_dao.g.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tasks_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$TasksDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$RoomsTable get rooms => attachedDatabase.rooms;
|
||||
$TasksTable get tasks => attachedDatabase.tasks;
|
||||
$TaskCompletionsTable get taskCompletions => attachedDatabase.taskCompletions;
|
||||
TasksDaoManager get managers => TasksDaoManager(this);
|
||||
}
|
||||
|
||||
class TasksDaoManager {
|
||||
final _$TasksDaoMixin _db;
|
||||
TasksDaoManager(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,
|
||||
);
|
||||
}
|
||||
23
lib/features/tasks/domain/effort_level.dart
Normal file
23
lib/features/tasks/domain/effort_level.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
/// Effort level for tasks.
|
||||
///
|
||||
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
|
||||
/// Always add new values at the END.
|
||||
enum EffortLevel {
|
||||
low, // 0
|
||||
medium, // 1
|
||||
high, // 2
|
||||
}
|
||||
|
||||
/// German display labels for effort levels.
|
||||
extension EffortLevelLabel on EffortLevel {
|
||||
String label() {
|
||||
switch (this) {
|
||||
case EffortLevel.low:
|
||||
return 'Gering';
|
||||
case EffortLevel.medium:
|
||||
return 'Mittel';
|
||||
case EffortLevel.high:
|
||||
return 'Hoch';
|
||||
}
|
||||
}
|
||||
}
|
||||
62
lib/features/tasks/domain/frequency.dart
Normal file
62
lib/features/tasks/domain/frequency.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
/// Frequency interval types for recurring tasks.
|
||||
///
|
||||
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
|
||||
/// Always add new values at the END.
|
||||
enum IntervalType {
|
||||
daily, // 0
|
||||
everyNDays, // 1
|
||||
weekly, // 2
|
||||
biweekly, // 3
|
||||
monthly, // 4
|
||||
everyNMonths, // 5
|
||||
quarterly, // 6
|
||||
yearly, // 7
|
||||
}
|
||||
|
||||
/// A frequency interval combining a type with an optional multiplier.
|
||||
class FrequencyInterval {
|
||||
final IntervalType intervalType;
|
||||
final int days;
|
||||
|
||||
const FrequencyInterval({required this.intervalType, this.days = 1});
|
||||
|
||||
/// German display label for this interval.
|
||||
String label() {
|
||||
switch (intervalType) {
|
||||
case IntervalType.daily:
|
||||
return 'Taeglich';
|
||||
case IntervalType.everyNDays:
|
||||
if (days == 7) return 'Woechentlich';
|
||||
if (days == 14) return 'Alle 2 Wochen';
|
||||
return 'Alle $days Tage';
|
||||
case IntervalType.weekly:
|
||||
return 'Woechentlich';
|
||||
case IntervalType.biweekly:
|
||||
return 'Alle 2 Wochen';
|
||||
case IntervalType.monthly:
|
||||
return 'Monatlich';
|
||||
case IntervalType.everyNMonths:
|
||||
if (days == 3) return 'Vierteljaehrlich';
|
||||
if (days == 6) return 'Halbjaehrlich';
|
||||
return 'Alle $days Monate';
|
||||
case IntervalType.quarterly:
|
||||
return 'Vierteljaehrlich';
|
||||
case IntervalType.yearly:
|
||||
return 'Jaehrlich';
|
||||
}
|
||||
}
|
||||
|
||||
/// All preset frequency intervals per TASK-04.
|
||||
static const List<FrequencyInterval> presets = [
|
||||
FrequencyInterval(intervalType: IntervalType.daily),
|
||||
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 2),
|
||||
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 3),
|
||||
FrequencyInterval(intervalType: IntervalType.weekly),
|
||||
FrequencyInterval(intervalType: IntervalType.biweekly),
|
||||
FrequencyInterval(intervalType: IntervalType.monthly),
|
||||
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 2),
|
||||
FrequencyInterval(intervalType: IntervalType.quarterly),
|
||||
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 6),
|
||||
FrequencyInterval(intervalType: IntervalType.yearly),
|
||||
];
|
||||
}
|
||||
18
lib/features/tasks/domain/relative_date.dart
Normal file
18
lib/features/tasks/domain/relative_date.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Format a due date relative to today in German.
|
||||
///
|
||||
/// Returns labels like "Heute", "Morgen", "in X Tagen",
|
||||
/// "Uberfaellig seit 1 Tag", "Uberfaellig seit X Tagen".
|
||||
///
|
||||
/// Both [dueDate] and [today] are compared as date-only (ignoring time).
|
||||
String formatRelativeDate(DateTime dueDate, DateTime today) {
|
||||
final diff =
|
||||
DateTime(dueDate.year, dueDate.month, dueDate.day)
|
||||
.difference(DateTime(today.year, today.month, today.day))
|
||||
.inDays;
|
||||
|
||||
if (diff == 0) return 'Heute';
|
||||
if (diff == 1) return 'Morgen';
|
||||
if (diff > 1) return 'in $diff Tagen';
|
||||
if (diff == -1) return 'Uberfaellig seit 1 Tag';
|
||||
return 'Uberfaellig seit ${diff.abs()} Tagen';
|
||||
}
|
||||
66
lib/features/tasks/domain/scheduling.dart
Normal file
66
lib/features/tasks/domain/scheduling.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'frequency.dart';
|
||||
|
||||
/// Calculate the next due date from the current due date and interval config.
|
||||
///
|
||||
/// For calendar-anchored intervals, [anchorDay] is the original day-of-month.
|
||||
/// Day-count intervals use pure arithmetic (Duration).
|
||||
/// Calendar-anchored intervals use month arithmetic with clamping.
|
||||
DateTime calculateNextDueDate({
|
||||
required DateTime currentDueDate,
|
||||
required IntervalType intervalType,
|
||||
required int intervalDays,
|
||||
int? anchorDay,
|
||||
}) {
|
||||
switch (intervalType) {
|
||||
// Day-count: pure arithmetic
|
||||
case IntervalType.daily:
|
||||
return currentDueDate.add(const Duration(days: 1));
|
||||
case IntervalType.everyNDays:
|
||||
return currentDueDate.add(Duration(days: intervalDays));
|
||||
case IntervalType.weekly:
|
||||
return currentDueDate.add(const Duration(days: 7));
|
||||
case IntervalType.biweekly:
|
||||
return currentDueDate.add(const Duration(days: 14));
|
||||
// Calendar-anchored: month arithmetic with clamping
|
||||
case IntervalType.monthly:
|
||||
return _addMonths(currentDueDate, 1, anchorDay);
|
||||
case IntervalType.everyNMonths:
|
||||
return _addMonths(currentDueDate, intervalDays, anchorDay);
|
||||
case IntervalType.quarterly:
|
||||
return _addMonths(currentDueDate, 3, anchorDay);
|
||||
case IntervalType.yearly:
|
||||
return _addMonths(currentDueDate, 12, anchorDay);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add months with day-of-month clamping.
|
||||
/// [anchorDay] remembers the original day for correct clamping.
|
||||
DateTime _addMonths(DateTime date, int months, int? anchorDay) {
|
||||
final targetMonth = date.month + months;
|
||||
final targetYear = date.year + (targetMonth - 1) ~/ 12;
|
||||
final normalizedMonth = ((targetMonth - 1) % 12) + 1;
|
||||
final day = anchorDay ?? date.day;
|
||||
// Last day of target month: day 0 of next month
|
||||
final lastDay = DateTime(targetYear, normalizedMonth + 1, 0).day;
|
||||
final clampedDay = day > lastDay ? lastDay : day;
|
||||
return DateTime(targetYear, normalizedMonth, clampedDay);
|
||||
}
|
||||
|
||||
/// Catch-up: if next due is in the past, keep adding intervals until future/today.
|
||||
DateTime catchUpToPresent({
|
||||
required DateTime nextDue,
|
||||
required DateTime today,
|
||||
required IntervalType intervalType,
|
||||
required int intervalDays,
|
||||
int? anchorDay,
|
||||
}) {
|
||||
while (nextDue.isBefore(today)) {
|
||||
nextDue = calculateNextDueDate(
|
||||
currentDueDate: nextDue,
|
||||
intervalType: intervalType,
|
||||
intervalDays: intervalDays,
|
||||
anchorDay: anchorDay,
|
||||
);
|
||||
}
|
||||
return nextDue;
|
||||
}
|
||||
Reference in New Issue
Block a user