feat(todo): recurring tasks, set through a picker (ADR 0033, D7)
A task's date may recur: every n days, weeks (on which weekdays), months (on its day, or its nth or last weekday), years, from its date or from when it is done, until a day or for n times. Done, it moves on to the next day after both its date and today, its deadline as far, its time kept, its done sub-tasks open again and when it was done kept in its payload; undo writes all of it back. The date sheet offers the rules that fit the task's day and a rule of the person's own; rows show a recurring date with its own icon, the task sheet the rule in words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
030c62ddde
commit
aa013f5a00
4 changed files with 1655 additions and 25 deletions
|
|
@ -8,8 +8,10 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
|
|||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
|
|
@ -382,6 +384,54 @@ void main() {
|
|||
expect(title.style?.decoration, TextDecoration.lineThrough);
|
||||
});
|
||||
|
||||
testWidgets('a task set to repeat every day, done, comes again '
|
||||
'tomorrow, when it was done kept', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Stretch',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(today),
|
||||
),
|
||||
);
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Stretch'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithIcon(ListTile, LucideIcons.calendar).last,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(ListTile, 'Repeat'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Every day'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Every day'), findsOneWidget, reason: 'in the sheet');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(ListTile, 'Stretch'),
|
||||
matching: find.byIcon(LucideIcons.repeat),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Stretch'), findsOneWidget, reason: 'still to do');
|
||||
expect(find.text('Tomorrow'), findsOneWidget);
|
||||
final stored = (await tester.runAsync(() => tasks().all()))!.single;
|
||||
expect(
|
||||
stored.content?.date,
|
||||
TaskDate(today.plus(1), repeat: const Repeat(RepeatUnit.day)),
|
||||
);
|
||||
expect(stored.content?.completions, hasLength(1));
|
||||
expect(stored.content?.completed, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Today puts what is overdue first; Reschedule moves it to '
|
||||
'the day picked', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// A recurrence (ADR 0033, D7): the day a task done moves on to, counted
|
||||
/// from its date or from the day it was done, and when it stops.
|
||||
void main() {
|
||||
// Monday 2 Nov 2026.
|
||||
const monday = Day(2026, 11, 2);
|
||||
|
||||
group('counted from its date', () {
|
||||
test('every n days, weeks, months and years', () {
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day).next(monday, today: monday),
|
||||
const Day(2026, 11, 3),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day, every: 3).next(monday, today: monday),
|
||||
const Day(2026, 11, 5),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.week, every: 2).next(monday, today: monday),
|
||||
const Day(2026, 11, 16),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month).next(monday, today: monday),
|
||||
const Day(2026, 12, 2),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.year).next(monday, today: monday),
|
||||
const Day(2027, 11, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('on given weekdays, every week or every other one', () {
|
||||
const weekdays = Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {DateTime.monday, DateTime.wednesday, DateTime.friday},
|
||||
);
|
||||
expect(weekdays.next(monday, today: monday), const Day(2026, 11, 4));
|
||||
expect(
|
||||
weekdays.next(const Day(2026, 11, 6), today: monday),
|
||||
const Day(2026, 11, 9),
|
||||
);
|
||||
const otherWeek = Repeat(
|
||||
RepeatUnit.week,
|
||||
every: 2,
|
||||
weekdays: {DateTime.monday, DateTime.thursday},
|
||||
);
|
||||
expect(otherWeek.next(monday, today: monday), const Day(2026, 11, 5));
|
||||
// The week after is skipped: Monday 16th, not the 9th.
|
||||
expect(
|
||||
otherWeek.next(const Day(2026, 11, 5), today: monday),
|
||||
const Day(2026, 11, 16),
|
||||
);
|
||||
});
|
||||
|
||||
test("on a month's day, kept through shorter months", () {
|
||||
const lastDay = Repeat(RepeatUnit.month, monthDay: 31);
|
||||
final february = lastDay.next(const Day(2027, 1, 31), today: monday);
|
||||
expect(february, const Day(2027, 2, 28));
|
||||
expect(lastDay.next(february!, today: monday), const Day(2027, 3, 31));
|
||||
const leap = Repeat(RepeatUnit.year, monthDay: 29);
|
||||
expect(
|
||||
leap.next(const Day(2028, 2, 29), today: monday),
|
||||
const Day(2029, 2, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test("on a month's nth or last weekday", () {
|
||||
// The first Monday of November 2026 is the 2nd.
|
||||
const first = Repeat(RepeatUnit.month, monthWeek: 1);
|
||||
expect(first.next(monday, today: monday), const Day(2026, 12, 7));
|
||||
const last = Repeat(RepeatUnit.month, monthWeek: -1);
|
||||
expect(
|
||||
last.next(const Day(2026, 11, 30), today: monday),
|
||||
const Day(2026, 12, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test('past today where it was overdue, never on a day already done', () {
|
||||
const daily = Repeat(RepeatUnit.day);
|
||||
expect(
|
||||
daily.next(const Day(2026, 10, 28), today: monday),
|
||||
const Day(2026, 11, 3),
|
||||
);
|
||||
const weekly = Repeat(RepeatUnit.week);
|
||||
expect(
|
||||
weekly.next(const Day(2026, 10, 19), today: monday),
|
||||
const Day(2026, 11, 9),
|
||||
);
|
||||
// Done early, it moves on from its own date.
|
||||
expect(
|
||||
daily.next(const Day(2026, 11, 5), today: monday),
|
||||
const Day(2026, 11, 6),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('counted from the day it was done, its date aside', () {
|
||||
const fromDone = Repeat(RepeatUnit.day, every: 3, fromCompletion: true);
|
||||
expect(
|
||||
fromDone.next(const Day(2026, 10, 20), today: monday),
|
||||
const Day(2026, 11, 5),
|
||||
);
|
||||
expect(
|
||||
const Repeat(
|
||||
RepeatUnit.month,
|
||||
fromCompletion: true,
|
||||
).next(const Day(2026, 12, 25), today: monday),
|
||||
const Day(2026, 12, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('stops after its last day or its last time', () {
|
||||
const until = Repeat(RepeatUnit.week, until: Day(2026, 11, 12));
|
||||
expect(until.next(monday, today: monday), const Day(2026, 11, 9));
|
||||
expect(until.next(const Day(2026, 11, 9), today: monday), isNull);
|
||||
const twice = Repeat(RepeatUnit.day, left: 1);
|
||||
expect(twice.next(monday, today: monday), const Day(2026, 11, 3));
|
||||
expect(twice.counted(), const Repeat(RepeatUnit.day, left: 0));
|
||||
expect(twice.counted().next(monday, today: monday), isNull);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.day).counted(),
|
||||
const Repeat(RepeatUnit.day),
|
||||
);
|
||||
});
|
||||
|
||||
test('starts on the first day it falls on', () {
|
||||
const weekdays = Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {
|
||||
DateTime.monday,
|
||||
DateTime.tuesday,
|
||||
DateTime.wednesday,
|
||||
DateTime.thursday,
|
||||
DateTime.friday,
|
||||
},
|
||||
);
|
||||
// Saturday 7th: Monday 9th.
|
||||
expect(weekdays.firstFrom(const Day(2026, 11, 7)), const Day(2026, 11, 9));
|
||||
expect(weekdays.firstFrom(monday), monday);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthWeek: -1).firstFrom(monday),
|
||||
const Day(2026, 11, 30),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthDay: 15).firstFrom(monday),
|
||||
const Day(2026, 11, 15),
|
||||
);
|
||||
expect(
|
||||
const Repeat(RepeatUnit.month, monthDay: 1).firstFrom(monday),
|
||||
const Day(2026, 12, 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("the nth and last weekday of a day's month", () {
|
||||
expect(monday.weekOfMonth, 1);
|
||||
expect(const Day(2026, 11, 30).weekOfMonth, 5);
|
||||
expect(const Day(2026, 11, 30).lastWeekOfMonth, isTrue);
|
||||
expect(const Day(2026, 11, 23).lastWeekOfMonth, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
|
|
@ -31,11 +32,18 @@ void main() {
|
|||
Day(2026, 11, 2),
|
||||
time: 6 * 60 + 30,
|
||||
duration: Duration(minutes: 30),
|
||||
repeat: Repeat(
|
||||
RepeatUnit.week,
|
||||
every: 2,
|
||||
weekdays: {DateTime.monday, DateTime.thursday},
|
||||
until: Day(2027, 3, 1),
|
||||
),
|
||||
),
|
||||
deadline: const Day(2026, 11, 8),
|
||||
priority: Priority.p2,
|
||||
labelIds: const ['l1', 'l2'],
|
||||
completedAt: DateTime(2026, 11, 2, 7),
|
||||
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
|
||||
);
|
||||
|
||||
group('a task', () {
|
||||
|
|
@ -55,11 +63,25 @@ void main() {
|
|||
'sectionId': 's',
|
||||
'parentId': 't',
|
||||
'position': 3.0,
|
||||
'date': {'day': '2026-11-02', 'time': 390, 'minutes': 30},
|
||||
'date': {
|
||||
'day': '2026-11-02',
|
||||
'time': 390,
|
||||
'minutes': 30,
|
||||
'repeat': {
|
||||
'unit': 'week',
|
||||
'every': 2,
|
||||
'weekdays': [1, 4],
|
||||
'until': '2027-03-01',
|
||||
},
|
||||
},
|
||||
'deadline': '2026-11-08',
|
||||
'priority': 2,
|
||||
'labelIds': ['l1', 'l2'],
|
||||
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
|
||||
'completions': [
|
||||
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),
|
||||
DateTime(2026, 10, 22, 6).toUtc().toIso8601String(),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
|
||||
|
|
@ -89,6 +111,26 @@ void main() {
|
|||
'date': {'day': '2026-11-02', 'time': 1440},
|
||||
},
|
||||
{...base, 'deadline': 'soon'},
|
||||
{
|
||||
...base,
|
||||
'completions': ['yesterday'],
|
||||
},
|
||||
for (final repeat in [
|
||||
{'unit': 'fortnight'},
|
||||
{'unit': 'day', 'every': 0},
|
||||
{
|
||||
'unit': 'week',
|
||||
'weekdays': [8],
|
||||
},
|
||||
{'unit': 'month', 'monthDay': 32},
|
||||
{'unit': 'month', 'monthWeek': 5},
|
||||
{'unit': 'day', 'left': -1},
|
||||
{'unit': 'day', 'until': 'never'},
|
||||
])
|
||||
{
|
||||
...base,
|
||||
'date': {'day': '2026-11-02', 'repeat': repeat},
|
||||
},
|
||||
{...base, 'date': 'today'},
|
||||
]) {
|
||||
expect(decodeTask(json(broken)), isNull, reason: '$broken');
|
||||
|
|
@ -96,6 +138,23 @@ void main() {
|
|||
expect(decodeTask(ByteData(0)), isNull);
|
||||
});
|
||||
|
||||
test('recurring in every way reads back as it was written', () {
|
||||
for (final repeat in const [
|
||||
Repeat(RepeatUnit.day),
|
||||
Repeat(RepeatUnit.day, every: 3, fromCompletion: true, left: 4),
|
||||
Repeat(RepeatUnit.month, monthDay: 31),
|
||||
Repeat(RepeatUnit.month, monthWeek: -1),
|
||||
Repeat(RepeatUnit.year, every: 2),
|
||||
]) {
|
||||
final task = TaskContent(
|
||||
title: 'A',
|
||||
projectId: 'p',
|
||||
date: const TaskDate(Day(2026, 11, 2)).repeating(repeat),
|
||||
);
|
||||
expect(decodeTask(encodeTask(task)), task, reason: '$repeat');
|
||||
}
|
||||
});
|
||||
|
||||
test('a priority it doesn’t know is none', () {
|
||||
expect(
|
||||
decodeTask(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
|
|
@ -80,6 +81,79 @@ void main() {
|
|||
expect(await phone.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('a recurring task done moves on with its deadline, its time kept and '
|
||||
'its sub-tasks open again; undone, all of it is as it was', () async {
|
||||
final phone = await device(alice);
|
||||
final weekly = milk.copyWith(
|
||||
date: () => milk.date!.repeating(
|
||||
const Repeat(RepeatUnit.week, left: 2),
|
||||
),
|
||||
deadline: () => const Day(2026, 11, 3),
|
||||
);
|
||||
final id = await phone.add(weekly);
|
||||
final doneAt = DateTime(2026, 10, 30);
|
||||
final sub = TaskContent(
|
||||
title: 'Oat',
|
||||
projectId: 'inbox',
|
||||
parentId: id,
|
||||
completedAt: doneAt,
|
||||
);
|
||||
final subId = await phone.add(sub);
|
||||
final other = await phone.add(milk.copyWith(completedAt: () => doneAt));
|
||||
|
||||
final at = DateTime(2026, 11, 2, 9, 30);
|
||||
final before = await withClock(
|
||||
Clock.fixed(at),
|
||||
() => phone.complete(StoredTask(id, weekly)),
|
||||
);
|
||||
|
||||
final after = {for (final t in await phone.all()) t.id: t.content};
|
||||
expect(
|
||||
after[id],
|
||||
weekly.copyWith(
|
||||
date: () => const TaskDate(
|
||||
Day(2026, 11, 9),
|
||||
time: 9 * 60,
|
||||
repeat: Repeat(RepeatUnit.week, left: 1),
|
||||
),
|
||||
deadline: () => const Day(2026, 11, 10),
|
||||
completions: [at],
|
||||
),
|
||||
);
|
||||
expect(after[subId]?.completed, isFalse);
|
||||
expect(after[other]?.completed, isTrue);
|
||||
|
||||
await phone.restore(before);
|
||||
expect(
|
||||
{for (final t in await phone.all()) t.id: t.content},
|
||||
{
|
||||
id: weekly,
|
||||
subId: sub,
|
||||
other: milk.copyWith(completedAt: () => doneAt),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a recurring task done its last time is done, its sub-tasks as they '
|
||||
'were', () async {
|
||||
final phone = await device(alice);
|
||||
final last = milk.copyWith(
|
||||
date: () => milk.date!.repeating(
|
||||
const Repeat(RepeatUnit.day, until: Day(2026, 11, 2)),
|
||||
),
|
||||
);
|
||||
final id = await phone.add(last);
|
||||
final at = DateTime(2026, 11, 2, 9, 30);
|
||||
await withClock(
|
||||
Clock.fixed(at),
|
||||
() => phone.complete(StoredTask(id, last)),
|
||||
);
|
||||
expect(
|
||||
(await phone.all()).single.content,
|
||||
last.copyWith(completedAt: () => at),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleting a task deletes its sub-tasks and theirs, and no other',
|
||||
() async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue