From d2e452655cc2ef0af84709a5e8a91fe6d5c58978 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 21:50:12 +0100 Subject: [PATCH] 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 --- .../household_keeper/drift_schema_v2.json | 338 +++ lib/core/database/database.dart | 67 +- lib/core/database/database.g.dart | 2335 ++++++++++++++++- lib/core/database/database.steps.dart | 247 ++ lib/features/rooms/data/rooms_dao.dart | 127 + lib/features/rooms/data/rooms_dao.g.dart | 25 + lib/features/rooms/domain/room_icons.dart | 40 + lib/features/tasks/data/tasks_dao.dart | 102 + lib/features/tasks/data/tasks_dao.g.dart | 25 + lib/features/tasks/domain/effort_level.dart | 23 + lib/features/tasks/domain/frequency.dart | 62 + lib/features/tasks/domain/relative_date.dart | 18 + lib/features/tasks/domain/scheduling.dart | 66 + test/core/database/database_test.dart | 4 +- .../household_keeper/migration_test.dart | 62 + test/features/rooms/data/rooms_dao_test.dart | 183 ++ test/features/tasks/data/tasks_dao_test.dart | 208 ++ .../tasks/domain/scheduling_test.dart | 156 ++ 18 files changed, 4082 insertions(+), 6 deletions(-) create mode 100644 drift_schemas/household_keeper/drift_schema_v2.json create mode 100644 lib/core/database/database.steps.dart create mode 100644 lib/features/rooms/data/rooms_dao.dart create mode 100644 lib/features/rooms/data/rooms_dao.g.dart create mode 100644 lib/features/rooms/domain/room_icons.dart create mode 100644 lib/features/tasks/data/tasks_dao.dart create mode 100644 lib/features/tasks/data/tasks_dao.g.dart create mode 100644 lib/features/tasks/domain/effort_level.dart create mode 100644 lib/features/tasks/domain/frequency.dart create mode 100644 lib/features/tasks/domain/relative_date.dart create mode 100644 lib/features/tasks/domain/scheduling.dart create mode 100644 test/drift/household_keeper/migration_test.dart create mode 100644 test/features/rooms/data/rooms_dao_test.dart create mode 100644 test/features/tasks/data/tasks_dao_test.dart create mode 100644 test/features/tasks/domain/scheduling_test.dart diff --git a/drift_schemas/household_keeper/drift_schema_v2.json b/drift_schemas/household_keeper/drift_schema_v2.json new file mode 100644 index 0000000..63c177f --- /dev/null +++ b/drift_schemas/household_keeper/drift_schema_v2.json @@ -0,0 +1,338 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "rooms", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 1, + "max": 100 + } + } + ] + }, + { + "name": "icon_name", + "getter_name": "iconName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": "() => DateTime.now()", + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 1, + "references": [ + 0 + ], + "type": "table", + "data": { + "name": "tasks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "room_id", + "getter_name": "roomId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES rooms (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES rooms (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "rooms", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "allowed-lengths": { + "min": 1, + "max": 200 + } + } + ] + }, + { + "name": "description", + "getter_name": "description", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "interval_type", + "getter_name": "intervalType", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(IntervalType.values)", + "dart_type_name": "IntervalType" + } + }, + { + "name": "interval_days", + "getter_name": "intervalDays", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "anchor_day", + "getter_name": "anchorDay", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "effort_level", + "getter_name": "effortLevel", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumIndexConverter(EffortLevel.values)", + "dart_type_name": "EffortLevel" + } + }, + { + "name": "next_due_date", + "getter_name": "nextDueDate", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": "() => DateTime.now()", + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "task_completions", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "PRIMARY KEY AUTOINCREMENT", + "dialectAwareDefaultConstraints": { + "sqlite": "PRIMARY KEY AUTOINCREMENT" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + "auto-increment" + ] + }, + { + "name": "task_id", + "getter_name": "taskId", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES tasks (id)", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES tasks (id)" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "tasks", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": null + } + } + ] + }, + { + "name": "completed_at", + "getter_name": "completedAt", + "moor_type": "dateTime", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [] + } + } + ], + "fixed_sql": [ + { + "name": "rooms", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"rooms\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" TEXT NOT NULL, \"icon_name\" TEXT NOT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "tasks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"tasks\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"room_id\" INTEGER NOT NULL REFERENCES rooms (id), \"name\" TEXT NOT NULL, \"description\" TEXT NULL, \"interval_type\" INTEGER NOT NULL, \"interval_days\" INTEGER NOT NULL DEFAULT 1, \"anchor_day\" INTEGER NULL, \"effort_level\" INTEGER NOT NULL, \"next_due_date\" INTEGER NOT NULL, \"created_at\" INTEGER NOT NULL);" + } + ] + }, + { + "name": "task_completions", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"task_completions\" (\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, \"task_id\" INTEGER NOT NULL REFERENCES tasks (id), \"completed_at\" INTEGER NOT NULL);" + } + ] + } + ] +} \ No newline at end of file diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 6169396..174b899 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -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()(); + IntColumn get intervalDays => + integer().withDefault(const Constant(1))(); + IntColumn get anchorDay => integer().nullable()(); + IntColumn get effortLevel => intEnum()(); + 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( diff --git a/lib/core/database/database.g.dart b/lib/core/database/database.g.dart index 40c484b..8878b41 100644 --- a/lib/core/database/database.g.dart +++ b/lib/core/database/database.g.dart @@ -3,17 +3,2350 @@ part of 'database.dart'; // ignore_for_file: type=lint +class $RoomsTable extends Rooms with TableInfo<$RoomsTable, Room> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RoomsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + maxTextLength: 100, + ), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _iconNameMeta = const VerificationMeta( + 'iconName', + ); + @override + late final GeneratedColumn iconName = GeneratedColumn( + 'icon_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sortOrderMeta = const VerificationMeta( + 'sortOrder', + ); + @override + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + name, + iconName, + sortOrder, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'rooms'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('icon_name')) { + context.handle( + _iconNameMeta, + iconName.isAcceptableOrUnknown(data['icon_name']!, _iconNameMeta), + ); + } else if (isInserting) { + context.missing(_iconNameMeta); + } + if (data.containsKey('sort_order')) { + context.handle( + _sortOrderMeta, + sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Room map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Room( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + iconName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}icon_name'], + )!, + sortOrder: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sort_order'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $RoomsTable createAlias(String alias) { + return $RoomsTable(attachedDatabase, alias); + } +} + +class Room extends DataClass implements Insertable { + final int id; + final String name; + final String iconName; + final int sortOrder; + final DateTime createdAt; + const Room({ + required this.id, + required this.name, + required this.iconName, + required this.sortOrder, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['icon_name'] = Variable(iconName); + map['sort_order'] = Variable(sortOrder); + map['created_at'] = Variable(createdAt); + return map; + } + + RoomsCompanion toCompanion(bool nullToAbsent) { + return RoomsCompanion( + id: Value(id), + name: Value(name), + iconName: Value(iconName), + sortOrder: Value(sortOrder), + createdAt: Value(createdAt), + ); + } + + factory Room.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Room( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + iconName: serializer.fromJson(json['iconName']), + sortOrder: serializer.fromJson(json['sortOrder']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'iconName': serializer.toJson(iconName), + 'sortOrder': serializer.toJson(sortOrder), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Room copyWith({ + int? id, + String? name, + String? iconName, + int? sortOrder, + DateTime? createdAt, + }) => Room( + id: id ?? this.id, + name: name ?? this.name, + iconName: iconName ?? this.iconName, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + ); + Room copyWithCompanion(RoomsCompanion data) { + return Room( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + iconName: data.iconName.present ? data.iconName.value : this.iconName, + sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Room(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('iconName: $iconName, ') + ..write('sortOrder: $sortOrder, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, iconName, sortOrder, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Room && + other.id == this.id && + other.name == this.name && + other.iconName == this.iconName && + other.sortOrder == this.sortOrder && + other.createdAt == this.createdAt); +} + +class RoomsCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value iconName; + final Value sortOrder; + final Value createdAt; + const RoomsCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.iconName = const Value.absent(), + this.sortOrder = const Value.absent(), + this.createdAt = const Value.absent(), + }); + RoomsCompanion.insert({ + this.id = const Value.absent(), + required String name, + required String iconName, + this.sortOrder = const Value.absent(), + this.createdAt = const Value.absent(), + }) : name = Value(name), + iconName = Value(iconName); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? iconName, + Expression? sortOrder, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (iconName != null) 'icon_name': iconName, + if (sortOrder != null) 'sort_order': sortOrder, + if (createdAt != null) 'created_at': createdAt, + }); + } + + RoomsCompanion copyWith({ + Value? id, + Value? name, + Value? iconName, + Value? sortOrder, + Value? createdAt, + }) { + return RoomsCompanion( + id: id ?? this.id, + name: name ?? this.name, + iconName: iconName ?? this.iconName, + sortOrder: sortOrder ?? this.sortOrder, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (iconName.present) { + map['icon_name'] = Variable(iconName.value); + } + if (sortOrder.present) { + map['sort_order'] = Variable(sortOrder.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RoomsCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('iconName: $iconName, ') + ..write('sortOrder: $sortOrder, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $TasksTable extends Tasks with TableInfo<$TasksTable, Task> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TasksTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _roomIdMeta = const VerificationMeta('roomId'); + @override + late final GeneratedColumn roomId = GeneratedColumn( + 'room_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES rooms (id)', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength( + minTextLength: 1, + maxTextLength: 200, + ), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter intervalType = + GeneratedColumn( + 'interval_type', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter($TasksTable.$converterintervalType); + static const VerificationMeta _intervalDaysMeta = const VerificationMeta( + 'intervalDays', + ); + @override + late final GeneratedColumn intervalDays = GeneratedColumn( + 'interval_days', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(1), + ); + static const VerificationMeta _anchorDayMeta = const VerificationMeta( + 'anchorDay', + ); + @override + late final GeneratedColumn anchorDay = GeneratedColumn( + 'anchor_day', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter effortLevel = + GeneratedColumn( + 'effort_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter($TasksTable.$convertereffortLevel); + static const VerificationMeta _nextDueDateMeta = const VerificationMeta( + 'nextDueDate', + ); + @override + late final GeneratedColumn nextDueDate = GeneratedColumn( + 'next_due_date', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + roomId, + name, + description, + intervalType, + intervalDays, + anchorDay, + effortLevel, + nextDueDate, + createdAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tasks'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('room_id')) { + context.handle( + _roomIdMeta, + roomId.isAcceptableOrUnknown(data['room_id']!, _roomIdMeta), + ); + } else if (isInserting) { + context.missing(_roomIdMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('interval_days')) { + context.handle( + _intervalDaysMeta, + intervalDays.isAcceptableOrUnknown( + data['interval_days']!, + _intervalDaysMeta, + ), + ); + } + if (data.containsKey('anchor_day')) { + context.handle( + _anchorDayMeta, + anchorDay.isAcceptableOrUnknown(data['anchor_day']!, _anchorDayMeta), + ); + } + if (data.containsKey('next_due_date')) { + context.handle( + _nextDueDateMeta, + nextDueDate.isAcceptableOrUnknown( + data['next_due_date']!, + _nextDueDateMeta, + ), + ); + } else if (isInserting) { + context.missing(_nextDueDateMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Task map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Task( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + roomId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}room_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + ), + intervalType: $TasksTable.$converterintervalType.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}interval_type'], + )!, + ), + intervalDays: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}interval_days'], + )!, + anchorDay: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}anchor_day'], + ), + effortLevel: $TasksTable.$convertereffortLevel.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}effort_level'], + )!, + ), + nextDueDate: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}next_due_date'], + )!, + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + ); + } + + @override + $TasksTable createAlias(String alias) { + return $TasksTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $converterintervalType = + const EnumIndexConverter(IntervalType.values); + static JsonTypeConverter2 $convertereffortLevel = + const EnumIndexConverter(EffortLevel.values); +} + +class Task extends DataClass implements Insertable { + final int id; + final int roomId; + final String name; + final String? description; + final IntervalType intervalType; + final int intervalDays; + final int? anchorDay; + final EffortLevel effortLevel; + final DateTime nextDueDate; + final DateTime createdAt; + const Task({ + required this.id, + required this.roomId, + required this.name, + this.description, + required this.intervalType, + required this.intervalDays, + this.anchorDay, + required this.effortLevel, + required this.nextDueDate, + required this.createdAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['room_id'] = Variable(roomId); + map['name'] = Variable(name); + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + { + map['interval_type'] = Variable( + $TasksTable.$converterintervalType.toSql(intervalType), + ); + } + map['interval_days'] = Variable(intervalDays); + if (!nullToAbsent || anchorDay != null) { + map['anchor_day'] = Variable(anchorDay); + } + { + map['effort_level'] = Variable( + $TasksTable.$convertereffortLevel.toSql(effortLevel), + ); + } + map['next_due_date'] = Variable(nextDueDate); + map['created_at'] = Variable(createdAt); + return map; + } + + TasksCompanion toCompanion(bool nullToAbsent) { + return TasksCompanion( + id: Value(id), + roomId: Value(roomId), + name: Value(name), + description: description == null && nullToAbsent + ? const Value.absent() + : Value(description), + intervalType: Value(intervalType), + intervalDays: Value(intervalDays), + anchorDay: anchorDay == null && nullToAbsent + ? const Value.absent() + : Value(anchorDay), + effortLevel: Value(effortLevel), + nextDueDate: Value(nextDueDate), + createdAt: Value(createdAt), + ); + } + + factory Task.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Task( + id: serializer.fromJson(json['id']), + roomId: serializer.fromJson(json['roomId']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + intervalType: $TasksTable.$converterintervalType.fromJson( + serializer.fromJson(json['intervalType']), + ), + intervalDays: serializer.fromJson(json['intervalDays']), + anchorDay: serializer.fromJson(json['anchorDay']), + effortLevel: $TasksTable.$convertereffortLevel.fromJson( + serializer.fromJson(json['effortLevel']), + ), + nextDueDate: serializer.fromJson(json['nextDueDate']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'roomId': serializer.toJson(roomId), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'intervalType': serializer.toJson( + $TasksTable.$converterintervalType.toJson(intervalType), + ), + 'intervalDays': serializer.toJson(intervalDays), + 'anchorDay': serializer.toJson(anchorDay), + 'effortLevel': serializer.toJson( + $TasksTable.$convertereffortLevel.toJson(effortLevel), + ), + 'nextDueDate': serializer.toJson(nextDueDate), + 'createdAt': serializer.toJson(createdAt), + }; + } + + Task copyWith({ + int? id, + int? roomId, + String? name, + Value description = const Value.absent(), + IntervalType? intervalType, + int? intervalDays, + Value anchorDay = const Value.absent(), + EffortLevel? effortLevel, + DateTime? nextDueDate, + DateTime? createdAt, + }) => Task( + id: id ?? this.id, + roomId: roomId ?? this.roomId, + name: name ?? this.name, + description: description.present ? description.value : this.description, + intervalType: intervalType ?? this.intervalType, + intervalDays: intervalDays ?? this.intervalDays, + anchorDay: anchorDay.present ? anchorDay.value : this.anchorDay, + effortLevel: effortLevel ?? this.effortLevel, + nextDueDate: nextDueDate ?? this.nextDueDate, + createdAt: createdAt ?? this.createdAt, + ); + Task copyWithCompanion(TasksCompanion data) { + return Task( + id: data.id.present ? data.id.value : this.id, + roomId: data.roomId.present ? data.roomId.value : this.roomId, + name: data.name.present ? data.name.value : this.name, + description: data.description.present + ? data.description.value + : this.description, + intervalType: data.intervalType.present + ? data.intervalType.value + : this.intervalType, + intervalDays: data.intervalDays.present + ? data.intervalDays.value + : this.intervalDays, + anchorDay: data.anchorDay.present ? data.anchorDay.value : this.anchorDay, + effortLevel: data.effortLevel.present + ? data.effortLevel.value + : this.effortLevel, + nextDueDate: data.nextDueDate.present + ? data.nextDueDate.value + : this.nextDueDate, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('Task(') + ..write('id: $id, ') + ..write('roomId: $roomId, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('intervalType: $intervalType, ') + ..write('intervalDays: $intervalDays, ') + ..write('anchorDay: $anchorDay, ') + ..write('effortLevel: $effortLevel, ') + ..write('nextDueDate: $nextDueDate, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + roomId, + name, + description, + intervalType, + intervalDays, + anchorDay, + effortLevel, + nextDueDate, + createdAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Task && + other.id == this.id && + other.roomId == this.roomId && + other.name == this.name && + other.description == this.description && + other.intervalType == this.intervalType && + other.intervalDays == this.intervalDays && + other.anchorDay == this.anchorDay && + other.effortLevel == this.effortLevel && + other.nextDueDate == this.nextDueDate && + other.createdAt == this.createdAt); +} + +class TasksCompanion extends UpdateCompanion { + final Value id; + final Value roomId; + final Value name; + final Value description; + final Value intervalType; + final Value intervalDays; + final Value anchorDay; + final Value effortLevel; + final Value nextDueDate; + final Value createdAt; + const TasksCompanion({ + this.id = const Value.absent(), + this.roomId = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.intervalType = const Value.absent(), + this.intervalDays = const Value.absent(), + this.anchorDay = const Value.absent(), + this.effortLevel = const Value.absent(), + this.nextDueDate = const Value.absent(), + this.createdAt = const Value.absent(), + }); + TasksCompanion.insert({ + this.id = const Value.absent(), + required int roomId, + required String name, + this.description = const Value.absent(), + required IntervalType intervalType, + this.intervalDays = const Value.absent(), + this.anchorDay = const Value.absent(), + required EffortLevel effortLevel, + required DateTime nextDueDate, + this.createdAt = const Value.absent(), + }) : roomId = Value(roomId), + name = Value(name), + intervalType = Value(intervalType), + effortLevel = Value(effortLevel), + nextDueDate = Value(nextDueDate); + static Insertable custom({ + Expression? id, + Expression? roomId, + Expression? name, + Expression? description, + Expression? intervalType, + Expression? intervalDays, + Expression? anchorDay, + Expression? effortLevel, + Expression? nextDueDate, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (roomId != null) 'room_id': roomId, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (intervalType != null) 'interval_type': intervalType, + if (intervalDays != null) 'interval_days': intervalDays, + if (anchorDay != null) 'anchor_day': anchorDay, + if (effortLevel != null) 'effort_level': effortLevel, + if (nextDueDate != null) 'next_due_date': nextDueDate, + if (createdAt != null) 'created_at': createdAt, + }); + } + + TasksCompanion copyWith({ + Value? id, + Value? roomId, + Value? name, + Value? description, + Value? intervalType, + Value? intervalDays, + Value? anchorDay, + Value? effortLevel, + Value? nextDueDate, + Value? createdAt, + }) { + return TasksCompanion( + id: id ?? this.id, + roomId: roomId ?? this.roomId, + name: name ?? this.name, + description: description ?? this.description, + intervalType: intervalType ?? this.intervalType, + intervalDays: intervalDays ?? this.intervalDays, + anchorDay: anchorDay ?? this.anchorDay, + effortLevel: effortLevel ?? this.effortLevel, + nextDueDate: nextDueDate ?? this.nextDueDate, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (roomId.present) { + map['room_id'] = Variable(roomId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (intervalType.present) { + map['interval_type'] = Variable( + $TasksTable.$converterintervalType.toSql(intervalType.value), + ); + } + if (intervalDays.present) { + map['interval_days'] = Variable(intervalDays.value); + } + if (anchorDay.present) { + map['anchor_day'] = Variable(anchorDay.value); + } + if (effortLevel.present) { + map['effort_level'] = Variable( + $TasksTable.$convertereffortLevel.toSql(effortLevel.value), + ); + } + if (nextDueDate.present) { + map['next_due_date'] = Variable(nextDueDate.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TasksCompanion(') + ..write('id: $id, ') + ..write('roomId: $roomId, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('intervalType: $intervalType, ') + ..write('intervalDays: $intervalDays, ') + ..write('anchorDay: $anchorDay, ') + ..write('effortLevel: $effortLevel, ') + ..write('nextDueDate: $nextDueDate, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $TaskCompletionsTable extends TaskCompletions + with TableInfo<$TaskCompletionsTable, TaskCompletion> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $TaskCompletionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _taskIdMeta = const VerificationMeta('taskId'); + @override + late final GeneratedColumn taskId = GeneratedColumn( + 'task_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES tasks (id)', + ), + ); + static const VerificationMeta _completedAtMeta = const VerificationMeta( + 'completedAt', + ); + @override + late final GeneratedColumn completedAt = GeneratedColumn( + 'completed_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, taskId, completedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'task_completions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('task_id')) { + context.handle( + _taskIdMeta, + taskId.isAcceptableOrUnknown(data['task_id']!, _taskIdMeta), + ); + } else if (isInserting) { + context.missing(_taskIdMeta); + } + if (data.containsKey('completed_at')) { + context.handle( + _completedAtMeta, + completedAt.isAcceptableOrUnknown( + data['completed_at']!, + _completedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_completedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + TaskCompletion map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TaskCompletion( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + taskId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}task_id'], + )!, + completedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}completed_at'], + )!, + ); + } + + @override + $TaskCompletionsTable createAlias(String alias) { + return $TaskCompletionsTable(attachedDatabase, alias); + } +} + +class TaskCompletion extends DataClass implements Insertable { + final int id; + final int taskId; + final DateTime completedAt; + const TaskCompletion({ + required this.id, + required this.taskId, + required this.completedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['task_id'] = Variable(taskId); + map['completed_at'] = Variable(completedAt); + return map; + } + + TaskCompletionsCompanion toCompanion(bool nullToAbsent) { + return TaskCompletionsCompanion( + id: Value(id), + taskId: Value(taskId), + completedAt: Value(completedAt), + ); + } + + factory TaskCompletion.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TaskCompletion( + id: serializer.fromJson(json['id']), + taskId: serializer.fromJson(json['taskId']), + completedAt: serializer.fromJson(json['completedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'taskId': serializer.toJson(taskId), + 'completedAt': serializer.toJson(completedAt), + }; + } + + TaskCompletion copyWith({int? id, int? taskId, DateTime? completedAt}) => + TaskCompletion( + id: id ?? this.id, + taskId: taskId ?? this.taskId, + completedAt: completedAt ?? this.completedAt, + ); + TaskCompletion copyWithCompanion(TaskCompletionsCompanion data) { + return TaskCompletion( + id: data.id.present ? data.id.value : this.id, + taskId: data.taskId.present ? data.taskId.value : this.taskId, + completedAt: data.completedAt.present + ? data.completedAt.value + : this.completedAt, + ); + } + + @override + String toString() { + return (StringBuffer('TaskCompletion(') + ..write('id: $id, ') + ..write('taskId: $taskId, ') + ..write('completedAt: $completedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, taskId, completedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TaskCompletion && + other.id == this.id && + other.taskId == this.taskId && + other.completedAt == this.completedAt); +} + +class TaskCompletionsCompanion extends UpdateCompanion { + final Value id; + final Value taskId; + final Value completedAt; + const TaskCompletionsCompanion({ + this.id = const Value.absent(), + this.taskId = const Value.absent(), + this.completedAt = const Value.absent(), + }); + TaskCompletionsCompanion.insert({ + this.id = const Value.absent(), + required int taskId, + required DateTime completedAt, + }) : taskId = Value(taskId), + completedAt = Value(completedAt); + static Insertable custom({ + Expression? id, + Expression? taskId, + Expression? completedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (taskId != null) 'task_id': taskId, + if (completedAt != null) 'completed_at': completedAt, + }); + } + + TaskCompletionsCompanion copyWith({ + Value? id, + Value? taskId, + Value? completedAt, + }) { + return TaskCompletionsCompanion( + id: id ?? this.id, + taskId: taskId ?? this.taskId, + completedAt: completedAt ?? this.completedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (taskId.present) { + map['task_id'] = Variable(taskId.value); + } + if (completedAt.present) { + map['completed_at'] = Variable(completedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TaskCompletionsCompanion(') + ..write('id: $id, ') + ..write('taskId: $taskId, ') + ..write('completedAt: $completedAt') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $RoomsTable rooms = $RoomsTable(this); + late final $TasksTable tasks = $TasksTable(this); + late final $TaskCompletionsTable taskCompletions = $TaskCompletionsTable( + this, + ); + late final RoomsDao roomsDao = RoomsDao(this as AppDatabase); + late final TasksDao tasksDao = TasksDao(this as AppDatabase); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => []; + List get allSchemaEntities => [ + rooms, + tasks, + taskCompletions, + ]; } +typedef $$RoomsTableCreateCompanionBuilder = + RoomsCompanion Function({ + Value id, + required String name, + required String iconName, + Value sortOrder, + Value createdAt, + }); +typedef $$RoomsTableUpdateCompanionBuilder = + RoomsCompanion Function({ + Value id, + Value name, + Value iconName, + Value sortOrder, + Value createdAt, + }); + +final class $$RoomsTableReferences + extends BaseReferences<_$AppDatabase, $RoomsTable, Room> { + $$RoomsTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$TasksTable, List> _tasksRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.tasks, + aliasName: $_aliasNameGenerator(db.rooms.id, db.tasks.roomId), + ); + + $$TasksTableProcessedTableManager get tasksRefs { + final manager = $$TasksTableTableManager( + $_db, + $_db.tasks, + ).filter((f) => f.roomId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_tasksRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$RoomsTableFilterComposer extends Composer<_$AppDatabase, $RoomsTable> { + $$RoomsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get iconName => $composableBuilder( + column: $table.iconName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + Expression tasksRefs( + Expression Function($$TasksTableFilterComposer f) f, + ) { + final $$TasksTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.roomId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableFilterComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RoomsTableOrderingComposer + extends Composer<_$AppDatabase, $RoomsTable> { + $$RoomsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get iconName => $composableBuilder( + column: $table.iconName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sortOrder => $composableBuilder( + column: $table.sortOrder, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RoomsTableAnnotationComposer + extends Composer<_$AppDatabase, $RoomsTable> { + $$RoomsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get iconName => + $composableBuilder(column: $table.iconName, builder: (column) => column); + + GeneratedColumn get sortOrder => + $composableBuilder(column: $table.sortOrder, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + Expression tasksRefs( + Expression Function($$TasksTableAnnotationComposer a) f, + ) { + final $$TasksTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.roomId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableAnnotationComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$RoomsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RoomsTable, + Room, + $$RoomsTableFilterComposer, + $$RoomsTableOrderingComposer, + $$RoomsTableAnnotationComposer, + $$RoomsTableCreateCompanionBuilder, + $$RoomsTableUpdateCompanionBuilder, + (Room, $$RoomsTableReferences), + Room, + PrefetchHooks Function({bool tasksRefs}) + > { + $$RoomsTableTableManager(_$AppDatabase db, $RoomsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RoomsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$RoomsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$RoomsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value iconName = const Value.absent(), + Value sortOrder = const Value.absent(), + Value createdAt = const Value.absent(), + }) => RoomsCompanion( + id: id, + name: name, + iconName: iconName, + sortOrder: sortOrder, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String name, + required String iconName, + Value sortOrder = const Value.absent(), + Value createdAt = const Value.absent(), + }) => RoomsCompanion.insert( + id: id, + name: name, + iconName: iconName, + sortOrder: sortOrder, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => + (e.readTable(table), $$RoomsTableReferences(db, table, e)), + ) + .toList(), + prefetchHooksCallback: ({tasksRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (tasksRefs) db.tasks], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tasksRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$RoomsTableReferences._tasksRefsTable( + db, + ), + managerFromTypedResult: (p0) => + $$RoomsTableReferences(db, table, p0).tasksRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.roomId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$RoomsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RoomsTable, + Room, + $$RoomsTableFilterComposer, + $$RoomsTableOrderingComposer, + $$RoomsTableAnnotationComposer, + $$RoomsTableCreateCompanionBuilder, + $$RoomsTableUpdateCompanionBuilder, + (Room, $$RoomsTableReferences), + Room, + PrefetchHooks Function({bool tasksRefs}) + >; +typedef $$TasksTableCreateCompanionBuilder = + TasksCompanion Function({ + Value id, + required int roomId, + required String name, + Value description, + required IntervalType intervalType, + Value intervalDays, + Value anchorDay, + required EffortLevel effortLevel, + required DateTime nextDueDate, + Value createdAt, + }); +typedef $$TasksTableUpdateCompanionBuilder = + TasksCompanion Function({ + Value id, + Value roomId, + Value name, + Value description, + Value intervalType, + Value intervalDays, + Value anchorDay, + Value effortLevel, + Value nextDueDate, + Value createdAt, + }); + +final class $$TasksTableReferences + extends BaseReferences<_$AppDatabase, $TasksTable, Task> { + $$TasksTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $RoomsTable _roomIdTable(_$AppDatabase db) => + db.rooms.createAlias($_aliasNameGenerator(db.tasks.roomId, db.rooms.id)); + + $$RoomsTableProcessedTableManager get roomId { + final $_column = $_itemColumn('room_id')!; + + final manager = $$RoomsTableTableManager( + $_db, + $_db.rooms, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_roomIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey<$TaskCompletionsTable, List> + _taskCompletionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( + db.taskCompletions, + aliasName: $_aliasNameGenerator(db.tasks.id, db.taskCompletions.taskId), + ); + + $$TaskCompletionsTableProcessedTableManager get taskCompletionsRefs { + final manager = $$TaskCompletionsTableTableManager( + $_db, + $_db.taskCompletions, + ).filter((f) => f.taskId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _taskCompletionsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$TasksTableFilterComposer extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get intervalType => $composableBuilder( + column: $table.intervalType, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get intervalDays => $composableBuilder( + column: $table.intervalDays, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get anchorDay => $composableBuilder( + column: $table.anchorDay, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get effortLevel => $composableBuilder( + column: $table.effortLevel, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get nextDueDate => $composableBuilder( + column: $table.nextDueDate, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + $$RoomsTableFilterComposer get roomId { + final $$RoomsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.roomId, + referencedTable: $db.rooms, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoomsTableFilterComposer( + $db: $db, + $table: $db.rooms, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression taskCompletionsRefs( + Expression Function($$TaskCompletionsTableFilterComposer f) f, + ) { + final $$TaskCompletionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.taskCompletions, + getReferencedColumn: (t) => t.taskId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TaskCompletionsTableFilterComposer( + $db: $db, + $table: $db.taskCompletions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TasksTableOrderingComposer + extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get intervalType => $composableBuilder( + column: $table.intervalType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get intervalDays => $composableBuilder( + column: $table.intervalDays, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get anchorDay => $composableBuilder( + column: $table.anchorDay, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get effortLevel => $composableBuilder( + column: $table.effortLevel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nextDueDate => $composableBuilder( + column: $table.nextDueDate, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + $$RoomsTableOrderingComposer get roomId { + final $$RoomsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.roomId, + referencedTable: $db.rooms, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoomsTableOrderingComposer( + $db: $db, + $table: $db.rooms, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TasksTableAnnotationComposer + extends Composer<_$AppDatabase, $TasksTable> { + $$TasksTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get intervalType => + $composableBuilder( + column: $table.intervalType, + builder: (column) => column, + ); + + GeneratedColumn get intervalDays => $composableBuilder( + column: $table.intervalDays, + builder: (column) => column, + ); + + GeneratedColumn get anchorDay => + $composableBuilder(column: $table.anchorDay, builder: (column) => column); + + GeneratedColumnWithTypeConverter get effortLevel => + $composableBuilder( + column: $table.effortLevel, + builder: (column) => column, + ); + + GeneratedColumn get nextDueDate => $composableBuilder( + column: $table.nextDueDate, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + $$RoomsTableAnnotationComposer get roomId { + final $$RoomsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.roomId, + referencedTable: $db.rooms, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$RoomsTableAnnotationComposer( + $db: $db, + $table: $db.rooms, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression taskCompletionsRefs( + Expression Function($$TaskCompletionsTableAnnotationComposer a) f, + ) { + final $$TaskCompletionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.taskCompletions, + getReferencedColumn: (t) => t.taskId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TaskCompletionsTableAnnotationComposer( + $db: $db, + $table: $db.taskCompletions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$TasksTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TasksTable, + Task, + $$TasksTableFilterComposer, + $$TasksTableOrderingComposer, + $$TasksTableAnnotationComposer, + $$TasksTableCreateCompanionBuilder, + $$TasksTableUpdateCompanionBuilder, + (Task, $$TasksTableReferences), + Task, + PrefetchHooks Function({bool roomId, bool taskCompletionsRefs}) + > { + $$TasksTableTableManager(_$AppDatabase db, $TasksTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TasksTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TasksTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TasksTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value roomId = const Value.absent(), + Value name = const Value.absent(), + Value description = const Value.absent(), + Value intervalType = const Value.absent(), + Value intervalDays = const Value.absent(), + Value anchorDay = const Value.absent(), + Value effortLevel = const Value.absent(), + Value nextDueDate = const Value.absent(), + Value createdAt = const Value.absent(), + }) => TasksCompanion( + id: id, + roomId: roomId, + name: name, + description: description, + intervalType: intervalType, + intervalDays: intervalDays, + anchorDay: anchorDay, + effortLevel: effortLevel, + nextDueDate: nextDueDate, + createdAt: createdAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int roomId, + required String name, + Value description = const Value.absent(), + required IntervalType intervalType, + Value intervalDays = const Value.absent(), + Value anchorDay = const Value.absent(), + required EffortLevel effortLevel, + required DateTime nextDueDate, + Value createdAt = const Value.absent(), + }) => TasksCompanion.insert( + id: id, + roomId: roomId, + name: name, + description: description, + intervalType: intervalType, + intervalDays: intervalDays, + anchorDay: anchorDay, + effortLevel: effortLevel, + nextDueDate: nextDueDate, + createdAt: createdAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => + (e.readTable(table), $$TasksTableReferences(db, table, e)), + ) + .toList(), + prefetchHooksCallback: + ({roomId = false, taskCompletionsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (taskCompletionsRefs) db.taskCompletions, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (roomId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.roomId, + referencedTable: $$TasksTableReferences + ._roomIdTable(db), + referencedColumn: $$TasksTableReferences + ._roomIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (taskCompletionsRefs) + await $_getPrefetchedData< + Task, + $TasksTable, + TaskCompletion + >( + currentTable: table, + referencedTable: $$TasksTableReferences + ._taskCompletionsRefsTable(db), + managerFromTypedResult: (p0) => + $$TasksTableReferences( + db, + table, + p0, + ).taskCompletionsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.taskId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$TasksTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TasksTable, + Task, + $$TasksTableFilterComposer, + $$TasksTableOrderingComposer, + $$TasksTableAnnotationComposer, + $$TasksTableCreateCompanionBuilder, + $$TasksTableUpdateCompanionBuilder, + (Task, $$TasksTableReferences), + Task, + PrefetchHooks Function({bool roomId, bool taskCompletionsRefs}) + >; +typedef $$TaskCompletionsTableCreateCompanionBuilder = + TaskCompletionsCompanion Function({ + Value id, + required int taskId, + required DateTime completedAt, + }); +typedef $$TaskCompletionsTableUpdateCompanionBuilder = + TaskCompletionsCompanion Function({ + Value id, + Value taskId, + Value completedAt, + }); + +final class $$TaskCompletionsTableReferences + extends + BaseReferences<_$AppDatabase, $TaskCompletionsTable, TaskCompletion> { + $$TaskCompletionsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $TasksTable _taskIdTable(_$AppDatabase db) => db.tasks.createAlias( + $_aliasNameGenerator(db.taskCompletions.taskId, db.tasks.id), + ); + + $$TasksTableProcessedTableManager get taskId { + final $_column = $_itemColumn('task_id')!; + + final manager = $$TasksTableTableManager( + $_db, + $_db.tasks, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_taskIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$TaskCompletionsTableFilterComposer + extends Composer<_$AppDatabase, $TaskCompletionsTable> { + $$TaskCompletionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get completedAt => $composableBuilder( + column: $table.completedAt, + builder: (column) => ColumnFilters(column), + ); + + $$TasksTableFilterComposer get taskId { + final $$TasksTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.taskId, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableFilterComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TaskCompletionsTableOrderingComposer + extends Composer<_$AppDatabase, $TaskCompletionsTable> { + $$TaskCompletionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get completedAt => $composableBuilder( + column: $table.completedAt, + builder: (column) => ColumnOrderings(column), + ); + + $$TasksTableOrderingComposer get taskId { + final $$TasksTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.taskId, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableOrderingComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TaskCompletionsTableAnnotationComposer + extends Composer<_$AppDatabase, $TaskCompletionsTable> { + $$TaskCompletionsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get completedAt => $composableBuilder( + column: $table.completedAt, + builder: (column) => column, + ); + + $$TasksTableAnnotationComposer get taskId { + final $$TasksTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.taskId, + referencedTable: $db.tasks, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$TasksTableAnnotationComposer( + $db: $db, + $table: $db.tasks, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$TaskCompletionsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $TaskCompletionsTable, + TaskCompletion, + $$TaskCompletionsTableFilterComposer, + $$TaskCompletionsTableOrderingComposer, + $$TaskCompletionsTableAnnotationComposer, + $$TaskCompletionsTableCreateCompanionBuilder, + $$TaskCompletionsTableUpdateCompanionBuilder, + (TaskCompletion, $$TaskCompletionsTableReferences), + TaskCompletion, + PrefetchHooks Function({bool taskId}) + > { + $$TaskCompletionsTableTableManager( + _$AppDatabase db, + $TaskCompletionsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TaskCompletionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TaskCompletionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TaskCompletionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value taskId = const Value.absent(), + Value completedAt = const Value.absent(), + }) => TaskCompletionsCompanion( + id: id, + taskId: taskId, + completedAt: completedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int taskId, + required DateTime completedAt, + }) => TaskCompletionsCompanion.insert( + id: id, + taskId: taskId, + completedAt: completedAt, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$TaskCompletionsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({taskId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (taskId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.taskId, + referencedTable: + $$TaskCompletionsTableReferences + ._taskIdTable(db), + referencedColumn: + $$TaskCompletionsTableReferences + ._taskIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$TaskCompletionsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $TaskCompletionsTable, + TaskCompletion, + $$TaskCompletionsTableFilterComposer, + $$TaskCompletionsTableOrderingComposer, + $$TaskCompletionsTableAnnotationComposer, + $$TaskCompletionsTableCreateCompanionBuilder, + $$TaskCompletionsTableUpdateCompanionBuilder, + (TaskCompletion, $$TaskCompletionsTableReferences), + TaskCompletion, + PrefetchHooks Function({bool taskId}) + >; + class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); + $$RoomsTableTableManager get rooms => + $$RoomsTableTableManager(_db, _db.rooms); + $$TasksTableTableManager get tasks => + $$TasksTableTableManager(_db, _db.tasks); + $$TaskCompletionsTableTableManager get taskCompletions => + $$TaskCompletionsTableTableManager(_db, _db.taskCompletions); } diff --git a/lib/core/database/database.steps.dart b/lib/core/database/database.steps.dart new file mode 100644 index 0000000..ac3d0f4 --- /dev/null +++ b/lib/core/database/database.steps.dart @@ -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 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 get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get iconName => + columnsByName['icon_name']! as i1.GeneratedColumn; + i1.GeneratedColumn get sortOrder => + columnsByName['sort_order']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT', + ); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn( + 'name', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn( + 'icon_name', + aliasedName, + false, + type: i1.DriftSqlType.string, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const i1.CustomExpression('0'), + ); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn( + '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 get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get roomId => + columnsByName['room_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get description => + columnsByName['description']! as i1.GeneratedColumn; + i1.GeneratedColumn get intervalType => + columnsByName['interval_type']! as i1.GeneratedColumn; + i1.GeneratedColumn get intervalDays => + columnsByName['interval_days']! as i1.GeneratedColumn; + i1.GeneratedColumn get anchorDay => + columnsByName['anchor_day']! as i1.GeneratedColumn; + i1.GeneratedColumn get effortLevel => + columnsByName['effort_level']! as i1.GeneratedColumn; + i1.GeneratedColumn get nextDueDate => + columnsByName['next_due_date']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn( + 'room_id', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL REFERENCES rooms(id)', + ); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn( + 'description', + aliasedName, + true, + type: i1.DriftSqlType.string, + $customConstraints: 'NULL', + ); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn( + 'interval_type', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn( + 'interval_days', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const i1.CustomExpression('1'), + ); +i1.GeneratedColumn _column_9(String aliasedName) => + i1.GeneratedColumn( + 'anchor_day', + aliasedName, + true, + type: i1.DriftSqlType.int, + $customConstraints: 'NULL', + ); +i1.GeneratedColumn _column_10(String aliasedName) => + i1.GeneratedColumn( + 'effort_level', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn( + '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 get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get taskId => + columnsByName['task_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get completedAt => + columnsByName['completed_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn( + 'task_id', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL REFERENCES tasks(id)', + ); +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn( + 'completed_at', + aliasedName, + false, + type: i1.DriftSqlType.int, + $customConstraints: 'NOT NULL', + ); +i0.MigrationStepWithVersion migrationSteps({ + required Future 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 Function(i1.Migrator m, Schema2 schema) from1To2, +}) => i0.VersionedSchema.stepByStepHelper( + step: migrationSteps(from1To2: from1To2), +); diff --git a/lib/features/rooms/data/rooms_dao.dart b/lib/features/rooms/data/rooms_dao.dart new file mode 100644 index 0000000..000f108 --- /dev/null +++ b/lib/features/rooms/data/rooms_dao.dart @@ -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 with _$RoomsDaoMixin { + RoomsDao(super.attachedDatabase); + + /// Watch all rooms ordered by sortOrder. + Stream> 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> watchRoomWithStats({DateTime? today}) { + final now = today ?? DateTime.now(); + final todayDateOnly = DateTime(now.year, now.month, now.day); + + return watchAllRooms().asyncMap((roomList) async { + final stats = []; + 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 insertRoom(RoomsCompanion room) => into(rooms).insert(room); + + /// Update an existing room. Returns true if a row was updated. + Future updateRoom(Room room) => update(rooms).replace(room); + + /// Delete a room and cascade to its tasks and completions. + Future 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 reorderRooms(List 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 getRoomById(int id) { + return (select(rooms)..where((r) => r.id.equals(id))).getSingle(); + } +} diff --git a/lib/features/rooms/data/rooms_dao.g.dart b/lib/features/rooms/data/rooms_dao.g.dart new file mode 100644 index 0000000..0967570 --- /dev/null +++ b/lib/features/rooms/data/rooms_dao.g.dart @@ -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 { + $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, + ); +} diff --git a/lib/features/rooms/domain/room_icons.dart b/lib/features/rooms/domain/room_icons.dart new file mode 100644 index 0000000..ba2f054 --- /dev/null +++ b/lib/features/rooms/domain/room_icons.dart @@ -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; +} diff --git a/lib/features/tasks/data/tasks_dao.dart b/lib/features/tasks/data/tasks_dao.dart new file mode 100644 index 0000000..422ac1c --- /dev/null +++ b/lib/features/tasks/data/tasks_dao.dart @@ -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 with _$TasksDaoMixin { + TasksDao(super.attachedDatabase); + + /// Watch tasks in a room sorted by nextDueDate ascending. + Stream> 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 insertTask(TasksCompanion task) => into(tasks).insert(task); + + /// Update an existing task. Returns true if a row was updated. + Future updateTask(Task task) => update(tasks).replace(task); + + /// Delete a task and its completions. + Future 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 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 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; + } +} diff --git a/lib/features/tasks/data/tasks_dao.g.dart b/lib/features/tasks/data/tasks_dao.g.dart new file mode 100644 index 0000000..7dce7a6 --- /dev/null +++ b/lib/features/tasks/data/tasks_dao.g.dart @@ -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 { + $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, + ); +} diff --git a/lib/features/tasks/domain/effort_level.dart b/lib/features/tasks/domain/effort_level.dart new file mode 100644 index 0000000..2dc3e04 --- /dev/null +++ b/lib/features/tasks/domain/effort_level.dart @@ -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'; + } + } +} diff --git a/lib/features/tasks/domain/frequency.dart b/lib/features/tasks/domain/frequency.dart new file mode 100644 index 0000000..0cb48fb --- /dev/null +++ b/lib/features/tasks/domain/frequency.dart @@ -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 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), + ]; +} diff --git a/lib/features/tasks/domain/relative_date.dart b/lib/features/tasks/domain/relative_date.dart new file mode 100644 index 0000000..eadb34a --- /dev/null +++ b/lib/features/tasks/domain/relative_date.dart @@ -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'; +} diff --git a/lib/features/tasks/domain/scheduling.dart b/lib/features/tasks/domain/scheduling.dart new file mode 100644 index 0000000..5b48f1a --- /dev/null +++ b/lib/features/tasks/domain/scheduling.dart @@ -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; +} diff --git a/test/core/database/database_test.dart b/test/core/database/database_test.dart index 61f99a8..d0b06a5 100644 --- a/test/core/database/database_test.dart +++ b/test/core/database/database_test.dart @@ -18,8 +18,8 @@ void main() { expect(db, isNotNull); }); - test('has schemaVersion 1', () { - expect(db.schemaVersion, equals(1)); + test('has schemaVersion 2', () { + expect(db.schemaVersion, equals(2)); }); test('can be closed without error', () async { diff --git a/test/drift/household_keeper/migration_test.dart b/test/drift/household_keeper/migration_test.dart new file mode 100644 index 0000000..f664d02 --- /dev/null +++ b/test/drift/household_keeper/migration_test.dart @@ -0,0 +1,62 @@ +// dart format width=80 +// ignore_for_file: unused_local_variable, unused_import +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:household_keeper/core/database/database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'generated/schema.dart'; + +import 'generated/schema_v1.dart' as v1; +import 'generated/schema_v2.dart' as v2; + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late SchemaVerifier verifier; + + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + group('simple database migrations', () { + // These simple tests verify all possible schema updates with a simple (no + // data) migration. This is a quick way to ensure that written database + // migrations properly alter the schema. + const versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from $fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test('to $toVersion', () async { + final schema = await verifier.schemaAt(fromVersion); + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + } + }); + } + }); + + // The following template shows how to write tests ensuring your migrations + // preserve existing data. + // Testing this can be useful for migrations that change existing columns + // (e.g. by alterating their type or constraints). Migrations that only add + // tables or columns typically don't need these advanced tests. For more + // information, see https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity + // TODO: This generated template shows how these tests could be written. Adopt + // it to your own needs when testing migrations with data integrity. + test('migration from v1 to v2 does not corrupt data', () async { + // Add data to insert into the old database, and the expected rows after the + // migration. + // TODO: Fill these lists + + await verifier.testWithDataIntegrity( + oldVersion: 1, + newVersion: 2, + createOld: v1.DatabaseAtV1.new, + createNew: v2.DatabaseAtV2.new, + openTestedDatabase: AppDatabase.new, + createItems: (batch, oldDb) {}, + validateItems: (newDb) async {}, + ); + }); +} diff --git a/test/features/rooms/data/rooms_dao_test.dart b/test/features/rooms/data/rooms_dao_test.dart new file mode 100644 index 0000000..bfe73d2 --- /dev/null +++ b/test/features/rooms/data/rooms_dao_test.dart @@ -0,0 +1,183 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:drift/drift.dart'; +import 'package:household_keeper/core/database/database.dart'; +import 'package:household_keeper/features/tasks/domain/effort_level.dart'; +import 'package:household_keeper/features/tasks/domain/frequency.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('RoomsDao', () { + test('insertRoom returns a valid id', () async { + final id = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + expect(id, greaterThan(0)); + }); + + test('watchAllRooms emits rooms ordered by sortOrder', () async { + // Insert rooms with different sort orders + await db.roomsDao.insertRoom( + RoomsCompanion.insert( + name: 'Badezimmer', + iconName: 'bathtub', + sortOrder: Value(2), + ), + ); + await db.roomsDao.insertRoom( + RoomsCompanion.insert( + name: 'Kueche', + iconName: 'kitchen', + sortOrder: Value(0), + ), + ); + await db.roomsDao.insertRoom( + RoomsCompanion.insert( + name: 'Schlafzimmer', + iconName: 'bed', + sortOrder: Value(1), + ), + ); + + final rooms = await db.roomsDao.watchAllRooms().first; + + expect(rooms.length, 3); + expect(rooms[0].name, 'Kueche'); + expect(rooms[1].name, 'Schlafzimmer'); + expect(rooms[2].name, 'Badezimmer'); + }); + + test('updateRoom changes name and iconName', () async { + final id = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + + final room = await db.roomsDao.getRoomById(id); + final updated = room.copyWith(name: 'Wohnkueche', iconName: 'dining'); + await db.roomsDao.updateRoom(updated); + + final result = await db.roomsDao.getRoomById(id); + expect(result.name, 'Wohnkueche'); + expect(result.iconName, 'dining'); + }); + + test('deleteRoom cascades to associated tasks and completions', () async { + // Create room + final roomId = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + + // Create task in the room + final taskId = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + // Complete the task to create a completion record + await db.tasksDao.completeTask(taskId, now: DateTime(2026, 3, 15)); + + // Verify task and completion exist + final tasksBefore = await db.tasksDao.watchTasksInRoom(roomId).first; + expect(tasksBefore.length, 1); + + // Delete room + await db.roomsDao.deleteRoom(roomId); + + // Verify room is gone + final rooms = await db.roomsDao.watchAllRooms().first; + expect(rooms, isEmpty); + + // Verify tasks are gone + final tasksAfter = await db.tasksDao.watchTasksInRoom(roomId).first; + expect(tasksAfter, isEmpty); + }); + + test('reorderRooms updates sortOrder for all rooms', () async { + final id1 = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + final id2 = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Bad', iconName: 'bathtub'), + ); + final id3 = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Schlafzimmer', iconName: 'bed'), + ); + + // Reorder: Bad first, then Schlafzimmer, then Kueche + await db.roomsDao.reorderRooms([id2, id3, id1]); + + final rooms = await db.roomsDao.watchAllRooms().first; + expect(rooms[0].name, 'Bad'); + expect(rooms[1].name, 'Schlafzimmer'); + expect(rooms[2].name, 'Kueche'); + }); + + test('watchRoomWithStats emits room with due task count and cleanliness ratio', () async { + final roomId = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + + final today = DateTime(2026, 3, 15); + + // Add a task due today (counts as "due") + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Task Due Today', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: today, + ), + ); + + // Add an overdue task (yesterday) + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Overdue Task', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 14), + ), + ); + + // Add a future task + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Future Task', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 20), + ), + ); + + final stats = await db.roomsDao.watchRoomWithStats(today: today).first; + + expect(stats.length, 1); + final roomStats = stats.first; + expect(roomStats.room.name, 'Kueche'); + expect(roomStats.totalTasks, 3); + // "Due" means on or before today: today (Mar 15) + overdue (Mar 14) = 2 + expect(roomStats.dueTasks, 2); + // "Overdue" means strictly before today: Mar 14 = 1 + expect(roomStats.overdueCount, 1); + // Cleanliness: (3 - 1) / 3 = 0.6667 + expect(roomStats.cleanlinessRatio, closeTo(0.6667, 0.001)); + }); + }); +} diff --git a/test/features/tasks/data/tasks_dao_test.dart b/test/features/tasks/data/tasks_dao_test.dart new file mode 100644 index 0000000..9a02d93 --- /dev/null +++ b/test/features/tasks/data/tasks_dao_test.dart @@ -0,0 +1,208 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:drift/drift.dart'; +import 'package:household_keeper/core/database/database.dart'; +import 'package:household_keeper/features/tasks/domain/effort_level.dart'; +import 'package:household_keeper/features/tasks/domain/frequency.dart'; + +void main() { + late AppDatabase db; + late int roomId; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + // Create a room for task tests + roomId = await db.roomsDao.insertRoom( + RoomsCompanion.insert(name: 'Kueche', iconName: 'kitchen'), + ); + }); + + tearDown(() async { + await db.close(); + }); + + group('TasksDao', () { + test('insertTask returns a valid id', () async { + final id = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + expect(id, greaterThan(0)); + }); + + test('watchTasksInRoom emits tasks sorted by nextDueDate ascending', () async { + // Insert tasks with different due dates (out of order) + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Later Task', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 20), + ), + ); + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Early Task', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 10), + ), + ); + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Middle Task', + intervalType: IntervalType.biweekly, + effortLevel: EffortLevel.high, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + final tasks = await db.tasksDao.watchTasksInRoom(roomId).first; + + expect(tasks.length, 3); + expect(tasks[0].name, 'Early Task'); + expect(tasks[1].name, 'Middle Task'); + expect(tasks[2].name, 'Later Task'); + }); + + test('updateTask changes name, description, interval, effort', () async { + final id = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + // Get the task, modify it, and update + final tasks = await db.tasksDao.watchTasksInRoom(roomId).first; + final task = tasks.first; + final updated = task.copyWith( + name: 'Geschirr spuelen', + description: Value('Mit Spuelmittel'), + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + ); + await db.tasksDao.updateTask(updated); + + final result = await db.tasksDao.watchTasksInRoom(roomId).first; + expect(result.first.name, 'Geschirr spuelen'); + expect(result.first.description, 'Mit Spuelmittel'); + expect(result.first.intervalType, IntervalType.weekly); + expect(result.first.effortLevel, EffortLevel.medium); + }); + + test('deleteTask removes the task', () async { + final id = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + await db.tasksDao.deleteTask(id); + + final tasks = await db.tasksDao.watchTasksInRoom(roomId).first; + expect(tasks, isEmpty); + }); + + test('completeTask records completion and updates nextDueDate', () async { + final id = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Abspuelen', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 15)); + + // Check that the next due date was updated (daily: +1 day) + final tasks = await db.tasksDao.watchTasksInRoom(roomId).first; + expect(tasks.first.nextDueDate, DateTime(2026, 3, 16)); + }); + + test('completeTask with overdue task catches up to present', () async { + // Task was due 2 weeks ago with weekly interval + final id = await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Staubsaugen', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 1), + ), + ); + + // Complete on March 15 + await db.tasksDao.completeTask(id, now: DateTime(2026, 3, 15)); + + // Should catch up: Mar 1 -> Mar 8 (still past) -> Mar 15 (equals today, done) + final tasks = await db.tasksDao.watchTasksInRoom(roomId).first; + final nextDue = tasks.first.nextDueDate; + // Next due should be on or after Mar 15 + expect( + nextDue.isAfter(DateTime(2026, 3, 14)) || + nextDue.isAtSameMomentAs(DateTime(2026, 3, 15)), + isTrue, + ); + }); + + test('tasks with nextDueDate before today are detected as overdue', () async { + // Insert an overdue task + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Overdue Task', + intervalType: IntervalType.weekly, + effortLevel: EffortLevel.medium, + nextDueDate: DateTime(2026, 3, 10), + ), + ); + + // Insert a task due today (not overdue) + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Today Task', + intervalType: IntervalType.daily, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 15), + ), + ); + + // Insert a future task (not overdue) + await db.tasksDao.insertTask( + TasksCompanion.insert( + roomId: roomId, + name: 'Future Task', + intervalType: IntervalType.monthly, + effortLevel: EffortLevel.low, + nextDueDate: DateTime(2026, 3, 20), + ), + ); + + final overdueCount = await db.tasksDao.getOverdueTaskCount( + roomId, + today: DateTime(2026, 3, 15), + ); + // Only the task due Mar 10 is overdue (before Mar 15) + expect(overdueCount, 1); + }); + }); +} diff --git a/test/features/tasks/domain/scheduling_test.dart b/test/features/tasks/domain/scheduling_test.dart new file mode 100644 index 0000000..4ef92b2 --- /dev/null +++ b/test/features/tasks/domain/scheduling_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:household_keeper/features/tasks/domain/frequency.dart'; +import 'package:household_keeper/features/tasks/domain/relative_date.dart'; +import 'package:household_keeper/features/tasks/domain/scheduling.dart'; + +void main() { + group('calculateNextDueDate', () { + test('daily adds 1 day', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 3, 15), + intervalType: IntervalType.daily, + intervalDays: 1, + ); + expect(result, DateTime(2026, 3, 16)); + }); + + test('everyNDays(3) adds 3 days', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 3, 15), + intervalType: IntervalType.everyNDays, + intervalDays: 3, + ); + expect(result, DateTime(2026, 3, 18)); + }); + + test('weekly adds 7 days', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 3, 15), + intervalType: IntervalType.weekly, + intervalDays: 1, + ); + expect(result, DateTime(2026, 3, 22)); + }); + + test('biweekly adds 14 days', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 3, 15), + intervalType: IntervalType.biweekly, + intervalDays: 1, + ); + expect(result, DateTime(2026, 3, 29)); + }); + + test('monthly from Jan 15 gives Feb 15', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 1, 15), + intervalType: IntervalType.monthly, + intervalDays: 1, + ); + expect(result, DateTime(2026, 2, 15)); + }); + + test('monthly from Jan 31 gives Feb 28 (clamping)', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 1, 31), + intervalType: IntervalType.monthly, + intervalDays: 1, + ); + expect(result, DateTime(2026, 2, 28)); + }); + + test('monthly from Feb 28 (anchor 31) gives Mar 31 (anchor memory)', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 2, 28), + intervalType: IntervalType.monthly, + intervalDays: 1, + anchorDay: 31, + ); + expect(result, DateTime(2026, 3, 31)); + }); + + test('quarterly from Jan 15 gives Apr 15', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 1, 15), + intervalType: IntervalType.quarterly, + intervalDays: 1, + ); + expect(result, DateTime(2026, 4, 15)); + }); + + test('yearly from 2026-03-15 gives 2027-03-15', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 3, 15), + intervalType: IntervalType.yearly, + intervalDays: 1, + ); + expect(result, DateTime(2027, 3, 15)); + }); + + test('everyNMonths(2) adds 2 months', () { + final result = calculateNextDueDate( + currentDueDate: DateTime(2026, 1, 15), + intervalType: IntervalType.everyNMonths, + intervalDays: 2, + ); + expect(result, DateTime(2026, 3, 15)); + }); + }); + + group('catchUpToPresent', () { + test('skips past occurrences to reach today or future', () { + // Task was due Jan 1, weekly interval. Today is Mar 15. + // Should advance past all missed weeks to the next week on/after Mar 15. + final result = catchUpToPresent( + nextDue: DateTime(2026, 1, 1), + today: DateTime(2026, 3, 15), + intervalType: IntervalType.weekly, + intervalDays: 1, + ); + // Jan 1 + 11 weeks = Mar 19 (first Thursday on/after Mar 15) + expect(result.isAfter(DateTime(2026, 3, 14)), isTrue); + expect(result.isBefore(DateTime(2026, 3, 22)), isTrue); + }); + + test('returns date unchanged if already in future', () { + final futureDate = DateTime(2026, 4, 1); + final result = catchUpToPresent( + nextDue: futureDate, + today: DateTime(2026, 3, 15), + intervalType: IntervalType.weekly, + intervalDays: 1, + ); + expect(result, futureDate); + }); + }); + + group('formatRelativeDate', () { + final today = DateTime(2026, 3, 15); + + test('today returns "Heute"', () { + expect(formatRelativeDate(DateTime(2026, 3, 15), today), 'Heute'); + }); + + test('tomorrow returns "Morgen"', () { + expect(formatRelativeDate(DateTime(2026, 3, 16), today), 'Morgen'); + }); + + test('3 days from now returns "in 3 Tagen"', () { + expect(formatRelativeDate(DateTime(2026, 3, 18), today), 'in 3 Tagen'); + }); + + test('1 day overdue returns "Uberfaellig seit 1 Tag"', () { + expect( + formatRelativeDate(DateTime(2026, 3, 14), today), + 'Uberfaellig seit 1 Tag', + ); + }); + + test('5 days overdue returns "Uberfaellig seit 5 Tagen"', () { + expect( + formatRelativeDate(DateTime(2026, 3, 10), today), + 'Uberfaellig seit 5 Tagen', + ); + }); + }); +}