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

@@ -2,14 +2,75 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
import '../../features/rooms/data/rooms_dao.dart';
import '../../features/tasks/data/tasks_dao.dart';
import '../../features/tasks/domain/effort_level.dart';
import '../../features/tasks/domain/frequency.dart';
part 'database.g.dart';
@DriftDatabase(tables: [])
/// Rooms table: each room has a name, icon, and sort order.
class Rooms extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text().withLength(min: 1, max: 100)();
TextColumn get iconName => text()();
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
DateTimeColumn get createdAt =>
dateTime().clientDefault(() => DateTime.now())();
}
/// Tasks table: each task belongs to a room and has scheduling info.
class Tasks extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get roomId => integer().references(Rooms, #id)();
TextColumn get name => text().withLength(min: 1, max: 200)();
TextColumn get description => text().nullable()();
IntColumn get intervalType => intEnum<IntervalType>()();
IntColumn get intervalDays =>
integer().withDefault(const Constant(1))();
IntColumn get anchorDay => integer().nullable()();
IntColumn get effortLevel => intEnum<EffortLevel>()();
DateTimeColumn get nextDueDate => dateTime()();
DateTimeColumn get createdAt =>
dateTime().clientDefault(() => DateTime.now())();
}
/// TaskCompletions table: records when a task was completed.
class TaskCompletions extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get taskId => integer().references(Tasks, #id)();
DateTimeColumn get completedAt => dateTime()();
}
@DriftDatabase(
tables: [Rooms, Tasks, TaskCompletions],
daos: [RoomsDao, TasksDao],
)
class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
AppDatabase([QueryExecutor? executor])
: super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
await m.createTable(rooms);
await m.createTable(tasks);
await m.createTable(taskCompletions);
}
},
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
static QueryExecutor _openConnection() {
return driftDatabase(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
// dart format width=80
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY.
// ignore_for_file: type=lint,unused_import
//
final class Schema2 extends i0.VersionedSchema {
Schema2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
rooms,
tasks,
taskCompletions,
];
late final Shape0 rooms = Shape0(
source: i0.VersionedTable(
entityName: 'rooms',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_0, _column_1, _column_2, _column_3, _column_4],
attachedDatabase: database,
),
alias: null,
);
late final Shape1 tasks = Shape1(
source: i0.VersionedTable(
entityName: 'tasks',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_5,
_column_1,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_4,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 taskCompletions = Shape2(
source: i0.VersionedTable(
entityName: 'task_completions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_0, _column_12, _column_13],
attachedDatabase: database,
),
alias: null,
);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get iconName =>
columnsByName['icon_name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get sortOrder =>
columnsByName['sort_order']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
i1.GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>(
'name',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
i1.GeneratedColumn<String>(
'icon_name',
aliasedName,
false,
type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_3(String aliasedName) =>
i1.GeneratedColumn<int>(
'sort_order',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 0',
defaultValue: const i1.CustomExpression('0'),
);
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
i1.GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
class Shape1 extends i0.VersionedTable {
Shape1({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get roomId =>
columnsByName['room_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get description =>
columnsByName['description']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get intervalType =>
columnsByName['interval_type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get intervalDays =>
columnsByName['interval_days']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get anchorDay =>
columnsByName['anchor_day']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get effortLevel =>
columnsByName['effort_level']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get nextDueDate =>
columnsByName['next_due_date']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_5(String aliasedName) =>
i1.GeneratedColumn<int>(
'room_id',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL REFERENCES rooms(id)',
);
i1.GeneratedColumn<String> _column_6(String aliasedName) =>
i1.GeneratedColumn<String>(
'description',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
i1.GeneratedColumn<int>(
'interval_type',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_8(String aliasedName) =>
i1.GeneratedColumn<int>(
'interval_days',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 1',
defaultValue: const i1.CustomExpression('1'),
);
i1.GeneratedColumn<int> _column_9(String aliasedName) =>
i1.GeneratedColumn<int>(
'anchor_day',
aliasedName,
true,
type: i1.DriftSqlType.int,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
i1.GeneratedColumn<int>(
'effort_level',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i1.GeneratedColumn<int> _column_11(String aliasedName) =>
i1.GeneratedColumn<int>(
'next_due_date',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
class Shape2 extends i0.VersionedTable {
Shape2({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<int> get id =>
columnsByName['id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get taskId =>
columnsByName['task_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get completedAt =>
columnsByName['completed_at']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_12(String aliasedName) =>
i1.GeneratedColumn<int>(
'task_id',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL REFERENCES tasks(id)',
);
i1.GeneratedColumn<int> _column_13(String aliasedName) =>
i1.GeneratedColumn<int>(
'completed_at',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = Schema2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(from1To2: from1To2),
);