feat(02-01): Drift tables, DAOs, scheduling utility, domain models with tests
- Add Rooms, Tasks, TaskCompletions Drift tables with schema v2 migration - Create RoomsDao with CRUD, watchAll, watchWithStats, cascade delete, reorder - Create TasksDao with CRUD, watchInRoom (sorted by due), completeTask, overdue detection - Implement calculateNextDueDate and catchUpToPresent pure scheduling functions - Define IntervalType enum (8 types), EffortLevel enum, FrequencyInterval model - Add formatRelativeDate German formatter and curatedRoomIcons icon list - Enable PRAGMA foreign_keys in beforeOpen migration strategy - All 30 unit tests passing (17 scheduling + 6 rooms DAO + 7 tasks DAO) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
338
drift_schemas/household_keeper/drift_schema_v2.json
Normal file
338
drift_schemas/household_keeper/drift_schema_v2.json
Normal file
@@ -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>(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>(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);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,14 +2,75 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift_flutter/drift_flutter.dart';
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
import 'package:path_provider/path_provider.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';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@DriftDatabase(tables: [])
|
/// Rooms table: each room has a name, icon, and sort order.
|
||||||
|
class Rooms extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 100)();
|
||||||
|
TextColumn get iconName => text()();
|
||||||
|
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||||||
|
DateTimeColumn get createdAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tasks table: each task belongs to a room and has scheduling info.
|
||||||
|
class Tasks extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get roomId => integer().references(Rooms, #id)();
|
||||||
|
TextColumn get name => text().withLength(min: 1, max: 200)();
|
||||||
|
TextColumn get description => text().nullable()();
|
||||||
|
IntColumn get intervalType => intEnum<IntervalType>()();
|
||||||
|
IntColumn get intervalDays =>
|
||||||
|
integer().withDefault(const Constant(1))();
|
||||||
|
IntColumn get anchorDay => integer().nullable()();
|
||||||
|
IntColumn get effortLevel => intEnum<EffortLevel>()();
|
||||||
|
DateTimeColumn get nextDueDate => dateTime()();
|
||||||
|
DateTimeColumn get createdAt =>
|
||||||
|
dateTime().clientDefault(() => DateTime.now())();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TaskCompletions table: records when a task was completed.
|
||||||
|
class TaskCompletions extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get taskId => integer().references(Tasks, #id)();
|
||||||
|
DateTimeColumn get completedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [Rooms, Tasks, TaskCompletions],
|
||||||
|
daos: [RoomsDao, TasksDao],
|
||||||
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor])
|
||||||
|
: super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@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() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(
|
return driftDatabase(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
247
lib/core/database/database.steps.dart
Normal file
247
lib/core/database/database.steps.dart
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// dart format width=80
|
||||||
|
import 'package:drift/internal/versioned_schema.dart' as i0;
|
||||||
|
import 'package:drift/drift.dart' as i1;
|
||||||
|
import 'package:drift/drift.dart'; // GENERATED BY drift_dev, DO NOT MODIFY.
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint,unused_import
|
||||||
|
//
|
||||||
|
final class Schema2 extends i0.VersionedSchema {
|
||||||
|
Schema2({required super.database}) : super(version: 2);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
rooms,
|
||||||
|
tasks,
|
||||||
|
taskCompletions,
|
||||||
|
];
|
||||||
|
late final Shape0 rooms = Shape0(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'rooms',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_0, _column_1, _column_2, _column_3, _column_4],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape1 tasks = Shape1(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'tasks',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_5,
|
||||||
|
_column_1,
|
||||||
|
_column_6,
|
||||||
|
_column_7,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_4,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape2 taskCompletions = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'task_completions',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_0, _column_12, _column_13],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape0 extends i0.VersionedTable {
|
||||||
|
Shape0({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get name =>
|
||||||
|
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get iconName =>
|
||||||
|
columnsByName['icon_name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get sortOrder =>
|
||||||
|
columnsByName['sort_order']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<int>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
hasAutoIncrement: true,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL PRIMARY KEY AUTOINCREMENT',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'icon_name',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_3(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'sort_order',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT 0',
|
||||||
|
defaultValue: const i1.CustomExpression('0'),
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'created_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
|
||||||
|
class Shape1 extends i0.VersionedTable {
|
||||||
|
Shape1({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get roomId =>
|
||||||
|
columnsByName['room_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get name =>
|
||||||
|
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get description =>
|
||||||
|
columnsByName['description']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get intervalType =>
|
||||||
|
columnsByName['interval_type']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get intervalDays =>
|
||||||
|
columnsByName['interval_days']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get anchorDay =>
|
||||||
|
columnsByName['anchor_day']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get effortLevel =>
|
||||||
|
columnsByName['effort_level']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get nextDueDate =>
|
||||||
|
columnsByName['next_due_date']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<int>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_5(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'room_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL REFERENCES rooms(id)',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<String> _column_6(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'description',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'interval_type',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_8(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'interval_days',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT 1',
|
||||||
|
defaultValue: const i1.CustomExpression('1'),
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_9(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'anchor_day',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'effort_level',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_11(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'next_due_date',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
|
||||||
|
class Shape2 extends i0.VersionedTable {
|
||||||
|
Shape2({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<int> get id =>
|
||||||
|
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get taskId =>
|
||||||
|
columnsByName['task_id']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get completedAt =>
|
||||||
|
columnsByName['completed_at']! as i1.GeneratedColumn<int>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<int> _column_12(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'task_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL REFERENCES tasks(id)',
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<int> _column_13(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<int>(
|
||||||
|
'completed_at',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.int,
|
||||||
|
$customConstraints: 'NOT NULL',
|
||||||
|
);
|
||||||
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
|
}) {
|
||||||
|
return (currentVersion, database) async {
|
||||||
|
switch (currentVersion) {
|
||||||
|
case 1:
|
||||||
|
final schema = Schema2(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from1To2(migrator, schema);
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.OnUpgrade stepByStep({
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
|
step: migrationSteps(from1To2: from1To2),
|
||||||
|
);
|
||||||
127
lib/features/rooms/data/rooms_dao.dart
Normal file
127
lib/features/rooms/data/rooms_dao.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
|
||||||
|
part 'rooms_dao.g.dart';
|
||||||
|
|
||||||
|
/// Stats for a room including task counts and cleanliness ratio.
|
||||||
|
class RoomWithStats {
|
||||||
|
final Room room;
|
||||||
|
final int totalTasks;
|
||||||
|
final int dueTasks;
|
||||||
|
final int overdueCount;
|
||||||
|
final double cleanlinessRatio;
|
||||||
|
|
||||||
|
const RoomWithStats({
|
||||||
|
required this.room,
|
||||||
|
required this.totalTasks,
|
||||||
|
required this.dueTasks,
|
||||||
|
required this.overdueCount,
|
||||||
|
required this.cleanlinessRatio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [Rooms, Tasks, TaskCompletions])
|
||||||
|
class RoomsDao extends DatabaseAccessor<AppDatabase> with _$RoomsDaoMixin {
|
||||||
|
RoomsDao(super.attachedDatabase);
|
||||||
|
|
||||||
|
/// Watch all rooms ordered by sortOrder.
|
||||||
|
Stream<List<Room>> watchAllRooms() {
|
||||||
|
return (select(rooms)..orderBy([(r) => OrderingTerm.asc(r.sortOrder)]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch all rooms with computed task stats.
|
||||||
|
///
|
||||||
|
/// Cleanliness ratio = (totalTasks - overdueCount) / totalTasks.
|
||||||
|
/// 1.0 when no tasks are overdue, 0.0 when all are overdue.
|
||||||
|
/// Rooms with no tasks have ratio 1.0.
|
||||||
|
Stream<List<RoomWithStats>> watchRoomWithStats({DateTime? today}) {
|
||||||
|
final now = today ?? DateTime.now();
|
||||||
|
final todayDateOnly = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
return watchAllRooms().asyncMap((roomList) async {
|
||||||
|
final stats = <RoomWithStats>[];
|
||||||
|
for (final room in roomList) {
|
||||||
|
final taskList = await (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(room.id)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final totalTasks = taskList.length;
|
||||||
|
var overdueCount = 0;
|
||||||
|
var dueTasks = 0;
|
||||||
|
|
||||||
|
for (final task in taskList) {
|
||||||
|
final dueDate = DateTime(
|
||||||
|
task.nextDueDate.year,
|
||||||
|
task.nextDueDate.month,
|
||||||
|
task.nextDueDate.day,
|
||||||
|
);
|
||||||
|
if (dueDate.isBefore(todayDateOnly) ||
|
||||||
|
dueDate.isAtSameMomentAs(todayDateOnly)) {
|
||||||
|
dueTasks++;
|
||||||
|
}
|
||||||
|
if (dueDate.isBefore(todayDateOnly)) {
|
||||||
|
overdueCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ratio =
|
||||||
|
totalTasks == 0 ? 1.0 : (totalTasks - overdueCount) / totalTasks;
|
||||||
|
|
||||||
|
stats.add(RoomWithStats(
|
||||||
|
room: room,
|
||||||
|
totalTasks: totalTasks,
|
||||||
|
dueTasks: dueTasks,
|
||||||
|
overdueCount: overdueCount,
|
||||||
|
cleanlinessRatio: ratio,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new room. Returns the auto-generated id.
|
||||||
|
Future<int> insertRoom(RoomsCompanion room) => into(rooms).insert(room);
|
||||||
|
|
||||||
|
/// Update an existing room. Returns true if a row was updated.
|
||||||
|
Future<bool> updateRoom(Room room) => update(rooms).replace(room);
|
||||||
|
|
||||||
|
/// Delete a room and cascade to its tasks and completions.
|
||||||
|
Future<void> deleteRoom(int roomId) {
|
||||||
|
return transaction(() async {
|
||||||
|
// Get all task IDs for this room
|
||||||
|
final taskList = await (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(roomId)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Delete completions for each task
|
||||||
|
for (final task in taskList) {
|
||||||
|
await (delete(taskCompletions)
|
||||||
|
..where((c) => c.taskId.equals(task.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete tasks
|
||||||
|
await (delete(tasks)..where((t) => t.roomId.equals(roomId))).go();
|
||||||
|
|
||||||
|
// Delete room
|
||||||
|
await (delete(rooms)..where((r) => r.id.equals(roomId))).go();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder rooms by updating sortOrder for each room in the list.
|
||||||
|
Future<void> reorderRooms(List<int> roomIds) {
|
||||||
|
return transaction(() async {
|
||||||
|
for (var i = 0; i < roomIds.length; i++) {
|
||||||
|
await (update(rooms)..where((r) => r.id.equals(roomIds[i])))
|
||||||
|
.write(RoomsCompanion(sortOrder: Value(i)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single room by its id.
|
||||||
|
Future<Room> getRoomById(int id) {
|
||||||
|
return (select(rooms)..where((r) => r.id.equals(id))).getSingle();
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/features/rooms/data/rooms_dao.g.dart
Normal file
25
lib/features/rooms/data/rooms_dao.g.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'rooms_dao.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
mixin _$RoomsDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||||
|
$RoomsTable get rooms => attachedDatabase.rooms;
|
||||||
|
$TasksTable get tasks => attachedDatabase.tasks;
|
||||||
|
$TaskCompletionsTable get taskCompletions => attachedDatabase.taskCompletions;
|
||||||
|
RoomsDaoManager get managers => RoomsDaoManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoomsDaoManager {
|
||||||
|
final _$RoomsDaoMixin _db;
|
||||||
|
RoomsDaoManager(this._db);
|
||||||
|
$$RoomsTableTableManager get rooms =>
|
||||||
|
$$RoomsTableTableManager(_db.attachedDatabase, _db.rooms);
|
||||||
|
$$TasksTableTableManager get tasks =>
|
||||||
|
$$TasksTableTableManager(_db.attachedDatabase, _db.tasks);
|
||||||
|
$$TaskCompletionsTableTableManager get taskCompletions =>
|
||||||
|
$$TaskCompletionsTableTableManager(
|
||||||
|
_db.attachedDatabase,
|
||||||
|
_db.taskCompletions,
|
||||||
|
);
|
||||||
|
}
|
||||||
40
lib/features/rooms/domain/room_icons.dart
Normal file
40
lib/features/rooms/domain/room_icons.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Curated list of ~25 household Material Icons for the room icon picker.
|
||||||
|
const List<({String name, IconData icon})> curatedRoomIcons = [
|
||||||
|
(name: 'kitchen', icon: Icons.kitchen),
|
||||||
|
(name: 'bathtub', icon: Icons.bathtub),
|
||||||
|
(name: 'bed', icon: Icons.bed),
|
||||||
|
(name: 'living', icon: Icons.living),
|
||||||
|
(name: 'weekend', icon: Icons.weekend),
|
||||||
|
(name: 'door_front', icon: Icons.door_front_door),
|
||||||
|
(name: 'desk', icon: Icons.desk),
|
||||||
|
(name: 'garage', icon: Icons.garage),
|
||||||
|
(name: 'balcony', icon: Icons.balcony),
|
||||||
|
(name: 'local_laundry', icon: Icons.local_laundry_service),
|
||||||
|
(name: 'stairs', icon: Icons.stairs),
|
||||||
|
(name: 'child_care', icon: Icons.child_care),
|
||||||
|
(name: 'single_bed', icon: Icons.single_bed),
|
||||||
|
(name: 'dining', icon: Icons.dining),
|
||||||
|
(name: 'yard', icon: Icons.yard),
|
||||||
|
(name: 'grass', icon: Icons.grass),
|
||||||
|
(name: 'home', icon: Icons.home),
|
||||||
|
(name: 'storage', icon: Icons.inventory_2),
|
||||||
|
(name: 'window', icon: Icons.window),
|
||||||
|
(name: 'cleaning', icon: Icons.cleaning_services),
|
||||||
|
(name: 'iron', icon: Icons.iron),
|
||||||
|
(name: 'microwave', icon: Icons.microwave),
|
||||||
|
(name: 'shower', icon: Icons.shower),
|
||||||
|
(name: 'chair', icon: Icons.chair),
|
||||||
|
(name: 'door_sliding', icon: Icons.door_sliding),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Map a stored icon name string back to its IconData.
|
||||||
|
///
|
||||||
|
/// Returns [Icons.home] as fallback if the name is not found.
|
||||||
|
IconData mapIconName(String name) {
|
||||||
|
for (final entry in curatedRoomIcons) {
|
||||||
|
if (entry.name == name) return entry.icon;
|
||||||
|
}
|
||||||
|
return Icons.home;
|
||||||
|
}
|
||||||
102
lib/features/tasks/data/tasks_dao.dart
Normal file
102
lib/features/tasks/data/tasks_dao.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../../../core/database/database.dart';
|
||||||
|
import '../domain/scheduling.dart';
|
||||||
|
|
||||||
|
part 'tasks_dao.g.dart';
|
||||||
|
|
||||||
|
@DriftAccessor(tables: [Tasks, TaskCompletions])
|
||||||
|
class TasksDao extends DatabaseAccessor<AppDatabase> with _$TasksDaoMixin {
|
||||||
|
TasksDao(super.attachedDatabase);
|
||||||
|
|
||||||
|
/// Watch tasks in a room sorted by nextDueDate ascending.
|
||||||
|
Stream<List<Task>> watchTasksInRoom(int roomId) {
|
||||||
|
return (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(roomId))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.nextDueDate)]))
|
||||||
|
.watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new task. Returns the auto-generated id.
|
||||||
|
Future<int> insertTask(TasksCompanion task) => into(tasks).insert(task);
|
||||||
|
|
||||||
|
/// Update an existing task. Returns true if a row was updated.
|
||||||
|
Future<bool> updateTask(Task task) => update(tasks).replace(task);
|
||||||
|
|
||||||
|
/// Delete a task and its completions.
|
||||||
|
Future<void> deleteTask(int taskId) {
|
||||||
|
return transaction(() async {
|
||||||
|
await (delete(taskCompletions)..where((c) => c.taskId.equals(taskId)))
|
||||||
|
.go();
|
||||||
|
await (delete(tasks)..where((t) => t.id.equals(taskId))).go();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a task as done: records completion and calculates next due date.
|
||||||
|
///
|
||||||
|
/// Uses scheduling utility for date calculation. Next due is calculated
|
||||||
|
/// from the original due date (not completion date) to keep rhythm stable.
|
||||||
|
/// If the calculated next due is in the past, catch-up advances to present.
|
||||||
|
///
|
||||||
|
/// [now] parameter allows injection of current time for testing.
|
||||||
|
Future<void> completeTask(int taskId, {DateTime? now}) {
|
||||||
|
return transaction(() async {
|
||||||
|
// 1. Get current task
|
||||||
|
final task =
|
||||||
|
await (select(tasks)..where((t) => t.id.equals(taskId))).getSingle();
|
||||||
|
|
||||||
|
final currentTime = now ?? DateTime.now();
|
||||||
|
|
||||||
|
// 2. Record completion
|
||||||
|
await into(taskCompletions).insert(TaskCompletionsCompanion.insert(
|
||||||
|
taskId: taskId,
|
||||||
|
completedAt: currentTime,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 3. Calculate next due date (from original due date, not today)
|
||||||
|
var nextDue = calculateNextDueDate(
|
||||||
|
currentDueDate: task.nextDueDate,
|
||||||
|
intervalType: task.intervalType,
|
||||||
|
intervalDays: task.intervalDays,
|
||||||
|
anchorDay: task.anchorDay,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Catch up if next due is still in the past
|
||||||
|
final todayDateOnly = DateTime(
|
||||||
|
currentTime.year,
|
||||||
|
currentTime.month,
|
||||||
|
currentTime.day,
|
||||||
|
);
|
||||||
|
nextDue = catchUpToPresent(
|
||||||
|
nextDue: nextDue,
|
||||||
|
today: todayDateOnly,
|
||||||
|
intervalType: task.intervalType,
|
||||||
|
intervalDays: task.intervalDays,
|
||||||
|
anchorDay: task.anchorDay,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Update task with new due date
|
||||||
|
await (update(tasks)..where((t) => t.id.equals(taskId)))
|
||||||
|
.write(TasksCompanion(nextDueDate: Value(nextDue)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count overdue tasks in a room (nextDueDate before today).
|
||||||
|
Future<int> getOverdueTaskCount(int roomId, {DateTime? today}) async {
|
||||||
|
final now = today ?? DateTime.now();
|
||||||
|
final todayDateOnly = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
|
final taskList = await (select(tasks)
|
||||||
|
..where((t) => t.roomId.equals(roomId)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return taskList.where((task) {
|
||||||
|
final dueDate = DateTime(
|
||||||
|
task.nextDueDate.year,
|
||||||
|
task.nextDueDate.month,
|
||||||
|
task.nextDueDate.day,
|
||||||
|
);
|
||||||
|
return dueDate.isBefore(todayDateOnly);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/features/tasks/data/tasks_dao.g.dart
Normal file
25
lib/features/tasks/data/tasks_dao.g.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'tasks_dao.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
mixin _$TasksDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||||
|
$RoomsTable get rooms => attachedDatabase.rooms;
|
||||||
|
$TasksTable get tasks => attachedDatabase.tasks;
|
||||||
|
$TaskCompletionsTable get taskCompletions => attachedDatabase.taskCompletions;
|
||||||
|
TasksDaoManager get managers => TasksDaoManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TasksDaoManager {
|
||||||
|
final _$TasksDaoMixin _db;
|
||||||
|
TasksDaoManager(this._db);
|
||||||
|
$$RoomsTableTableManager get rooms =>
|
||||||
|
$$RoomsTableTableManager(_db.attachedDatabase, _db.rooms);
|
||||||
|
$$TasksTableTableManager get tasks =>
|
||||||
|
$$TasksTableTableManager(_db.attachedDatabase, _db.tasks);
|
||||||
|
$$TaskCompletionsTableTableManager get taskCompletions =>
|
||||||
|
$$TaskCompletionsTableTableManager(
|
||||||
|
_db.attachedDatabase,
|
||||||
|
_db.taskCompletions,
|
||||||
|
);
|
||||||
|
}
|
||||||
23
lib/features/tasks/domain/effort_level.dart
Normal file
23
lib/features/tasks/domain/effort_level.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/// Effort level for tasks.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
|
||||||
|
/// Always add new values at the END.
|
||||||
|
enum EffortLevel {
|
||||||
|
low, // 0
|
||||||
|
medium, // 1
|
||||||
|
high, // 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// German display labels for effort levels.
|
||||||
|
extension EffortLevelLabel on EffortLevel {
|
||||||
|
String label() {
|
||||||
|
switch (this) {
|
||||||
|
case EffortLevel.low:
|
||||||
|
return 'Gering';
|
||||||
|
case EffortLevel.medium:
|
||||||
|
return 'Mittel';
|
||||||
|
case EffortLevel.high:
|
||||||
|
return 'Hoch';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
lib/features/tasks/domain/frequency.dart
Normal file
62
lib/features/tasks/domain/frequency.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/// Frequency interval types for recurring tasks.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: Never reorder or remove values - intEnum stores the .index.
|
||||||
|
/// Always add new values at the END.
|
||||||
|
enum IntervalType {
|
||||||
|
daily, // 0
|
||||||
|
everyNDays, // 1
|
||||||
|
weekly, // 2
|
||||||
|
biweekly, // 3
|
||||||
|
monthly, // 4
|
||||||
|
everyNMonths, // 5
|
||||||
|
quarterly, // 6
|
||||||
|
yearly, // 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frequency interval combining a type with an optional multiplier.
|
||||||
|
class FrequencyInterval {
|
||||||
|
final IntervalType intervalType;
|
||||||
|
final int days;
|
||||||
|
|
||||||
|
const FrequencyInterval({required this.intervalType, this.days = 1});
|
||||||
|
|
||||||
|
/// German display label for this interval.
|
||||||
|
String label() {
|
||||||
|
switch (intervalType) {
|
||||||
|
case IntervalType.daily:
|
||||||
|
return 'Taeglich';
|
||||||
|
case IntervalType.everyNDays:
|
||||||
|
if (days == 7) return 'Woechentlich';
|
||||||
|
if (days == 14) return 'Alle 2 Wochen';
|
||||||
|
return 'Alle $days Tage';
|
||||||
|
case IntervalType.weekly:
|
||||||
|
return 'Woechentlich';
|
||||||
|
case IntervalType.biweekly:
|
||||||
|
return 'Alle 2 Wochen';
|
||||||
|
case IntervalType.monthly:
|
||||||
|
return 'Monatlich';
|
||||||
|
case IntervalType.everyNMonths:
|
||||||
|
if (days == 3) return 'Vierteljaehrlich';
|
||||||
|
if (days == 6) return 'Halbjaehrlich';
|
||||||
|
return 'Alle $days Monate';
|
||||||
|
case IntervalType.quarterly:
|
||||||
|
return 'Vierteljaehrlich';
|
||||||
|
case IntervalType.yearly:
|
||||||
|
return 'Jaehrlich';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All preset frequency intervals per TASK-04.
|
||||||
|
static const List<FrequencyInterval> presets = [
|
||||||
|
FrequencyInterval(intervalType: IntervalType.daily),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 2),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.everyNDays, days: 3),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.weekly),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.biweekly),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.monthly),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 2),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.quarterly),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.everyNMonths, days: 6),
|
||||||
|
FrequencyInterval(intervalType: IntervalType.yearly),
|
||||||
|
];
|
||||||
|
}
|
||||||
18
lib/features/tasks/domain/relative_date.dart
Normal file
18
lib/features/tasks/domain/relative_date.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/// Format a due date relative to today in German.
|
||||||
|
///
|
||||||
|
/// Returns labels like "Heute", "Morgen", "in X Tagen",
|
||||||
|
/// "Uberfaellig seit 1 Tag", "Uberfaellig seit X Tagen".
|
||||||
|
///
|
||||||
|
/// Both [dueDate] and [today] are compared as date-only (ignoring time).
|
||||||
|
String formatRelativeDate(DateTime dueDate, DateTime today) {
|
||||||
|
final diff =
|
||||||
|
DateTime(dueDate.year, dueDate.month, dueDate.day)
|
||||||
|
.difference(DateTime(today.year, today.month, today.day))
|
||||||
|
.inDays;
|
||||||
|
|
||||||
|
if (diff == 0) return 'Heute';
|
||||||
|
if (diff == 1) return 'Morgen';
|
||||||
|
if (diff > 1) return 'in $diff Tagen';
|
||||||
|
if (diff == -1) return 'Uberfaellig seit 1 Tag';
|
||||||
|
return 'Uberfaellig seit ${diff.abs()} Tagen';
|
||||||
|
}
|
||||||
66
lib/features/tasks/domain/scheduling.dart
Normal file
66
lib/features/tasks/domain/scheduling.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'frequency.dart';
|
||||||
|
|
||||||
|
/// Calculate the next due date from the current due date and interval config.
|
||||||
|
///
|
||||||
|
/// For calendar-anchored intervals, [anchorDay] is the original day-of-month.
|
||||||
|
/// Day-count intervals use pure arithmetic (Duration).
|
||||||
|
/// Calendar-anchored intervals use month arithmetic with clamping.
|
||||||
|
DateTime calculateNextDueDate({
|
||||||
|
required DateTime currentDueDate,
|
||||||
|
required IntervalType intervalType,
|
||||||
|
required int intervalDays,
|
||||||
|
int? anchorDay,
|
||||||
|
}) {
|
||||||
|
switch (intervalType) {
|
||||||
|
// Day-count: pure arithmetic
|
||||||
|
case IntervalType.daily:
|
||||||
|
return currentDueDate.add(const Duration(days: 1));
|
||||||
|
case IntervalType.everyNDays:
|
||||||
|
return currentDueDate.add(Duration(days: intervalDays));
|
||||||
|
case IntervalType.weekly:
|
||||||
|
return currentDueDate.add(const Duration(days: 7));
|
||||||
|
case IntervalType.biweekly:
|
||||||
|
return currentDueDate.add(const Duration(days: 14));
|
||||||
|
// Calendar-anchored: month arithmetic with clamping
|
||||||
|
case IntervalType.monthly:
|
||||||
|
return _addMonths(currentDueDate, 1, anchorDay);
|
||||||
|
case IntervalType.everyNMonths:
|
||||||
|
return _addMonths(currentDueDate, intervalDays, anchorDay);
|
||||||
|
case IntervalType.quarterly:
|
||||||
|
return _addMonths(currentDueDate, 3, anchorDay);
|
||||||
|
case IntervalType.yearly:
|
||||||
|
return _addMonths(currentDueDate, 12, anchorDay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add months with day-of-month clamping.
|
||||||
|
/// [anchorDay] remembers the original day for correct clamping.
|
||||||
|
DateTime _addMonths(DateTime date, int months, int? anchorDay) {
|
||||||
|
final targetMonth = date.month + months;
|
||||||
|
final targetYear = date.year + (targetMonth - 1) ~/ 12;
|
||||||
|
final normalizedMonth = ((targetMonth - 1) % 12) + 1;
|
||||||
|
final day = anchorDay ?? date.day;
|
||||||
|
// Last day of target month: day 0 of next month
|
||||||
|
final lastDay = DateTime(targetYear, normalizedMonth + 1, 0).day;
|
||||||
|
final clampedDay = day > lastDay ? lastDay : day;
|
||||||
|
return DateTime(targetYear, normalizedMonth, clampedDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catch-up: if next due is in the past, keep adding intervals until future/today.
|
||||||
|
DateTime catchUpToPresent({
|
||||||
|
required DateTime nextDue,
|
||||||
|
required DateTime today,
|
||||||
|
required IntervalType intervalType,
|
||||||
|
required int intervalDays,
|
||||||
|
int? anchorDay,
|
||||||
|
}) {
|
||||||
|
while (nextDue.isBefore(today)) {
|
||||||
|
nextDue = calculateNextDueDate(
|
||||||
|
currentDueDate: nextDue,
|
||||||
|
intervalType: intervalType,
|
||||||
|
intervalDays: intervalDays,
|
||||||
|
anchorDay: anchorDay,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nextDue;
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ void main() {
|
|||||||
expect(db, isNotNull);
|
expect(db, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has schemaVersion 1', () {
|
test('has schemaVersion 2', () {
|
||||||
expect(db.schemaVersion, equals(1));
|
expect(db.schemaVersion, equals(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be closed without error', () async {
|
test('can be closed without error', () async {
|
||||||
|
|||||||
62
test/drift/household_keeper/migration_test.dart
Normal file
62
test/drift/household_keeper/migration_test.dart
Normal file
@@ -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 {},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
183
test/features/rooms/data/rooms_dao_test.dart
Normal file
183
test/features/rooms/data/rooms_dao_test.dart
Normal file
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
208
test/features/tasks/data/tasks_dao_test.dart
Normal file
208
test/features/tasks/data/tasks_dao_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
156
test/features/tasks/domain/scheduling_test.dart
Normal file
156
test/features/tasks/domain/scheduling_test.dart
Normal file
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user