feat(02-01): Drift tables, DAOs, scheduling utility, domain models with tests

- Add Rooms, Tasks, TaskCompletions Drift tables with schema v2 migration
- Create RoomsDao with CRUD, watchAll, watchWithStats, cascade delete, reorder
- Create TasksDao with CRUD, watchInRoom (sorted by due), completeTask, overdue detection
- Implement calculateNextDueDate and catchUpToPresent pure scheduling functions
- Define IntervalType enum (8 types), EffortLevel enum, FrequencyInterval model
- Add formatRelativeDate German formatter and curatedRoomIcons icon list
- Enable PRAGMA foreign_keys in beforeOpen migration strategy
- All 30 unit tests passing (17 scheduling + 6 rooms DAO + 7 tasks DAO)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 21:50:12 +01:00
parent 515304b432
commit d2e452655c
18 changed files with 4082 additions and 6 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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();
}
}

View 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,
);
}

View 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;
}

View 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;
}
}

View 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,
);
}

View 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';
}
}
}

View 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),
];
}

View 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';
}

View 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;
}