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:
Tizian 「ティツィアーン」 2026-11-01 12:15:51 +01:00
commit aa013f5a00
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 1655 additions and 25 deletions

View file

@ -2,7 +2,7 @@
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing. What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing.
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). - **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone. - **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`). - **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description) and its sheet (`openTaskSheet`), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list. - **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.

View file

@ -94,5 +94,30 @@
"todayEmptyHint": "Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.", "todayEmptyHint": "Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.",
"lockedRows": "{count, plural, =1{1 Eintrag lässt sich auf diesem Gerät nicht öffnen.} other{{count} Einträge lassen sich auf diesem Gerät nicht öffnen.}}", "lockedRows": "{count, plural, =1{1 Eintrag lässt sich auf diesem Gerät nicht öffnen.} other{{count} Einträge lassen sich auf diesem Gerät nicht öffnen.}}",
"inboxMissingTitle": "Noch kein Eingang", "inboxMissingTitle": "Noch kein Eingang",
"inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat." "inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.",
"repeatTitle": "Wiederholen",
"repeatNone": "Keine Wiederholung",
"repeatCustom": "Eigene Regel",
"repeatEveryDay": "{count, plural, =1{Jeden Tag} other{Alle {count} Tage}}",
"repeatEveryWeek": "{count, plural, =1{Jede Woche} other{Alle {count} Wochen}}",
"repeatEveryMonth": "{count, plural, =1{Jeden Monat} other{Alle {count} Monate}}",
"repeatEveryYear": "{count, plural, =1{Jedes Jahr} other{Alle {count} Jahre}}",
"repeatWeekdays": "Jeden Werktag",
"repeatOn": "{rule} am {on}",
"repeatMonthDay": "{day}.",
"repeatMonthWeek": "{nth, select, 1{ersten} 2{zweiten} 3{dritten} 4{vierten} other{letzten}} {weekday}",
"repeatFromCompletion": "{rule}, ab Erledigung",
"repeatUntil": "{rule}, bis {day}",
"repeatLeft": "{rule}, {count, plural, =0{nur noch diesmal} =1{noch einmal} other{noch {count}-mal}}",
"repeatEvery": "Alle",
"repeatUnitDays": "Tage",
"repeatUnitWeeks": "Wochen",
"repeatUnitMonths": "Monate",
"repeatUnitYears": "Jahre",
"repeatCountFromCompletion": "Ab Erledigung zählen",
"repeatCountFromCompletionHint": "Nicht ab ihrem Datum: später erledigt, kommt sie entsprechend später wieder.",
"repeatEnds": "Endet",
"repeatEndsNever": "Nie",
"repeatEndsOn": "An einem Tag",
"repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}"
} }

View file

@ -108,5 +108,41 @@
"lockedRows": "{count, plural, =1{1 item doesn't open on this device.} other{{count} items don't open on this device.}}", "lockedRows": "{count, plural, =1{1 item doesn't open on this device.} other{{count} items don't open on this device.}}",
"@lockedRows": {"placeholders": {"count": {"type": "int"}}}, "@lockedRows": {"placeholders": {"count": {"type": "int"}}},
"inboxMissingTitle": "No Inbox yet", "inboxMissingTitle": "No Inbox yet",
"inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys." "inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys.",
"repeatTitle": "Repeat",
"repeatNone": "Doesn't repeat",
"repeatCustom": "Custom",
"repeatEveryDay": "{count, plural, =1{Every day} other{Every {count} days}}",
"@repeatEveryDay": {"placeholders": {"count": {"type": "int"}}},
"repeatEveryWeek": "{count, plural, =1{Every week} other{Every {count} weeks}}",
"@repeatEveryWeek": {"placeholders": {"count": {"type": "int"}}},
"repeatEveryMonth": "{count, plural, =1{Every month} other{Every {count} months}}",
"@repeatEveryMonth": {"placeholders": {"count": {"type": "int"}}},
"repeatEveryYear": "{count, plural, =1{Every year} other{Every {count} years}}",
"@repeatEveryYear": {"placeholders": {"count": {"type": "int"}}},
"repeatWeekdays": "Every weekday",
"repeatOn": "{rule} on {on}",
"@repeatOn": {"placeholders": {"rule": {"type": "String"}, "on": {"type": "String"}}},
"repeatMonthDay": "day {day}",
"@repeatMonthDay": {"placeholders": {"day": {"type": "int"}}},
"repeatMonthWeek": "the {nth, select, 1{first} 2{second} 3{third} 4{fourth} other{last}} {weekday}",
"@repeatMonthWeek": {"placeholders": {"nth": {"type": "String"}, "weekday": {"type": "String"}}},
"repeatFromCompletion": "{rule}, from when it's done",
"@repeatFromCompletion": {"placeholders": {"rule": {"type": "String"}}},
"repeatUntil": "{rule}, until {day}",
"@repeatUntil": {"placeholders": {"rule": {"type": "String"}, "day": {"type": "String"}}},
"repeatLeft": "{rule}, {count, plural, =0{this time only} =1{once more} other{{count} more times}}",
"@repeatLeft": {"placeholders": {"rule": {"type": "String"}, "count": {"type": "int"}}},
"repeatEvery": "Every",
"repeatUnitDays": "Days",
"repeatUnitWeeks": "Weeks",
"repeatUnitMonths": "Months",
"repeatUnitYears": "Years",
"repeatCountFromCompletion": "Count from when it's done",
"repeatCountFromCompletionHint": "Not from its date: done late, it comes again as long after.",
"repeatEnds": "Ends",
"repeatEndsNever": "Never",
"repeatEndsOn": "On a day",
"repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}",
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}}
} }

View file

@ -633,6 +633,156 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'** /// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'**
String get inboxMissingHint; String get inboxMissingHint;
/// No description provided for @repeatTitle.
///
/// In en, this message translates to:
/// **'Repeat'**
String get repeatTitle;
/// No description provided for @repeatNone.
///
/// In en, this message translates to:
/// **'Doesn\'t repeat'**
String get repeatNone;
/// No description provided for @repeatCustom.
///
/// In en, this message translates to:
/// **'Custom'**
String get repeatCustom;
/// No description provided for @repeatEveryDay.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Every day} other{Every {count} days}}'**
String repeatEveryDay(int count);
/// No description provided for @repeatEveryWeek.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Every week} other{Every {count} weeks}}'**
String repeatEveryWeek(int count);
/// No description provided for @repeatEveryMonth.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Every month} other{Every {count} months}}'**
String repeatEveryMonth(int count);
/// No description provided for @repeatEveryYear.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Every year} other{Every {count} years}}'**
String repeatEveryYear(int count);
/// No description provided for @repeatWeekdays.
///
/// In en, this message translates to:
/// **'Every weekday'**
String get repeatWeekdays;
/// No description provided for @repeatOn.
///
/// In en, this message translates to:
/// **'{rule} on {on}'**
String repeatOn(String rule, String on);
/// No description provided for @repeatMonthDay.
///
/// In en, this message translates to:
/// **'day {day}'**
String repeatMonthDay(int day);
/// No description provided for @repeatMonthWeek.
///
/// In en, this message translates to:
/// **'the {nth, select, 1{first} 2{second} 3{third} 4{fourth} other{last}} {weekday}'**
String repeatMonthWeek(String nth, String weekday);
/// No description provided for @repeatFromCompletion.
///
/// In en, this message translates to:
/// **'{rule}, from when it\'s done'**
String repeatFromCompletion(String rule);
/// No description provided for @repeatUntil.
///
/// In en, this message translates to:
/// **'{rule}, until {day}'**
String repeatUntil(String rule, String day);
/// No description provided for @repeatLeft.
///
/// In en, this message translates to:
/// **'{rule}, {count, plural, =0{this time only} =1{once more} other{{count} more times}}'**
String repeatLeft(String rule, int count);
/// No description provided for @repeatEvery.
///
/// In en, this message translates to:
/// **'Every'**
String get repeatEvery;
/// No description provided for @repeatUnitDays.
///
/// In en, this message translates to:
/// **'Days'**
String get repeatUnitDays;
/// No description provided for @repeatUnitWeeks.
///
/// In en, this message translates to:
/// **'Weeks'**
String get repeatUnitWeeks;
/// No description provided for @repeatUnitMonths.
///
/// In en, this message translates to:
/// **'Months'**
String get repeatUnitMonths;
/// No description provided for @repeatUnitYears.
///
/// In en, this message translates to:
/// **'Years'**
String get repeatUnitYears;
/// No description provided for @repeatCountFromCompletion.
///
/// In en, this message translates to:
/// **'Count from when it\'s done'**
String get repeatCountFromCompletion;
/// No description provided for @repeatCountFromCompletionHint.
///
/// In en, this message translates to:
/// **'Not from its date: done late, it comes again as long after.'**
String get repeatCountFromCompletionHint;
/// No description provided for @repeatEnds.
///
/// In en, this message translates to:
/// **'Ends'**
String get repeatEnds;
/// No description provided for @repeatEndsNever.
///
/// In en, this message translates to:
/// **'Never'**
String get repeatEndsNever;
/// No description provided for @repeatEndsOn.
///
/// In en, this message translates to:
/// **'On a day'**
String get repeatEndsOn;
/// No description provided for @repeatEndsAfter.
///
/// In en, this message translates to:
/// **'{count, plural, =1{After once} other{After {count} times}}'**
String repeatEndsAfter(int count);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -322,4 +322,149 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get inboxMissingHint => String get inboxMissingHint =>
'Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.'; 'Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.';
@override
String get repeatTitle => 'Wiederholen';
@override
String get repeatNone => 'Keine Wiederholung';
@override
String get repeatCustom => 'Eigene Regel';
@override
String repeatEveryDay(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Alle $count Tage',
one: 'Jeden Tag',
);
return '$_temp0';
}
@override
String repeatEveryWeek(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Alle $count Wochen',
one: 'Jede Woche',
);
return '$_temp0';
}
@override
String repeatEveryMonth(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Alle $count Monate',
one: 'Jeden Monat',
);
return '$_temp0';
}
@override
String repeatEveryYear(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Alle $count Jahre',
one: 'Jedes Jahr',
);
return '$_temp0';
}
@override
String get repeatWeekdays => 'Jeden Werktag';
@override
String repeatOn(String rule, String on) {
return '$rule am $on';
}
@override
String repeatMonthDay(int day) {
return '$day.';
}
@override
String repeatMonthWeek(String nth, String weekday) {
String _temp0 = intl.Intl.selectLogic(
nth,
{
'1': 'ersten',
'2': 'zweiten',
'3': 'dritten',
'4': 'vierten',
'other': 'letzten',
},
);
return '$_temp0 $weekday';
}
@override
String repeatFromCompletion(String rule) {
return '$rule, ab Erledigung';
}
@override
String repeatUntil(String rule, String day) {
return '$rule, bis $day';
}
@override
String repeatLeft(String rule, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'noch $count-mal',
one: 'noch einmal',
zero: 'nur noch diesmal',
);
return '$rule, $_temp0';
}
@override
String get repeatEvery => 'Alle';
@override
String get repeatUnitDays => 'Tage';
@override
String get repeatUnitWeeks => 'Wochen';
@override
String get repeatUnitMonths => 'Monate';
@override
String get repeatUnitYears => 'Jahre';
@override
String get repeatCountFromCompletion => 'Ab Erledigung zählen';
@override
String get repeatCountFromCompletionHint =>
'Nicht ab ihrem Datum: später erledigt, kommt sie entsprechend später wieder.';
@override
String get repeatEnds => 'Endet';
@override
String get repeatEndsNever => 'Nie';
@override
String get repeatEndsOn => 'An einem Tag';
@override
String repeatEndsAfter(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Nach $count Malen',
one: 'Nach einem Mal',
);
return '$_temp0';
}
} }

View file

@ -322,6 +322,151 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get inboxMissingHint => String get inboxMissingHint =>
'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'; 'Your Inbox comes with the next sync, or once this device holds your account\'s keys.';
@override
String get repeatTitle => 'Repeat';
@override
String get repeatNone => 'Doesn\'t repeat';
@override
String get repeatCustom => 'Custom';
@override
String repeatEveryDay(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Every $count days',
one: 'Every day',
);
return '$_temp0';
}
@override
String repeatEveryWeek(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Every $count weeks',
one: 'Every week',
);
return '$_temp0';
}
@override
String repeatEveryMonth(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Every $count months',
one: 'Every month',
);
return '$_temp0';
}
@override
String repeatEveryYear(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Every $count years',
one: 'Every year',
);
return '$_temp0';
}
@override
String get repeatWeekdays => 'Every weekday';
@override
String repeatOn(String rule, String on) {
return '$rule on $on';
}
@override
String repeatMonthDay(int day) {
return 'day $day';
}
@override
String repeatMonthWeek(String nth, String weekday) {
String _temp0 = intl.Intl.selectLogic(
nth,
{
'1': 'first',
'2': 'second',
'3': 'third',
'4': 'fourth',
'other': 'last',
},
);
return 'the $_temp0 $weekday';
}
@override
String repeatFromCompletion(String rule) {
return '$rule, from when it\'s done';
}
@override
String repeatUntil(String rule, String day) {
return '$rule, until $day';
}
@override
String repeatLeft(String rule, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count more times',
one: 'once more',
zero: 'this time only',
);
return '$rule, $_temp0';
}
@override
String get repeatEvery => 'Every';
@override
String get repeatUnitDays => 'Days';
@override
String get repeatUnitWeeks => 'Weeks';
@override
String get repeatUnitMonths => 'Months';
@override
String get repeatUnitYears => 'Years';
@override
String get repeatCountFromCompletion => 'Count from when it\'s done';
@override
String get repeatCountFromCompletionHint =>
'Not from its date: done late, it comes again as long after.';
@override
String get repeatEnds => 'Ends';
@override
String get repeatEndsNever => 'Never';
@override
String get repeatEndsOn => 'On a day';
@override
String repeatEndsAfter(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'After $count times',
one: 'After once',
);
return '$_temp0';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,6 +1,7 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart'; import 'package:halkyon_todo_flutter/src/core/sealed/json_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_todo_flutter/src/feature/task/model/task.dart';
/// The schema of a task's payload this app writes and reads. /// The schema of a task's payload this app writes and reads.
@ -8,7 +9,9 @@ const taskSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, days as /// [content] as its row's payload, before sealing: UTF-8 JSON, days as
/// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes, /// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes,
/// the priority as its number, when it was done in UTC. /// the priority as its number, when it was done in UTC; a recurrence (D7)
/// within its date, by its unit's name and only what differs from its
/// defaults.
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, { ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
'title': content.title, 'title': content.title,
'projectId': content.projectId, 'projectId': content.projectId,
@ -21,14 +24,30 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
'day': '${date.day}', 'day': '${date.day}',
'time': ?date.time, 'time': ?date.time,
'minutes': ?date.duration?.inMinutes, 'minutes': ?date.duration?.inMinutes,
if (date.repeat case final repeat?) 'repeat': _encodeRepeat(repeat),
}, },
if (content.deadline case final deadline?) 'deadline': '$deadline', if (content.deadline case final deadline?) 'deadline': '$deadline',
if (content.priority != Priority.none) 'priority': content.priority.number, if (content.priority != Priority.none) 'priority': content.priority.number,
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds, if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
if (content.completedAt case final at?) if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(), 'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty)
'completions': [
for (final at in content.completions) at.toUtc().toIso8601String(),
],
}); });
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
'unit': repeat.unit.name,
if (repeat.every != 1) 'every': repeat.every,
if (repeat.weekdays.isNotEmpty) 'weekdays': [...repeat.weekdays]..sort(),
'monthDay': ?repeat.monthDay,
'monthWeek': ?repeat.monthWeek,
if (repeat.fromCompletion) 'fromCompletion': true,
if (repeat.until case final until?) 'until': '$until',
'left': ?repeat.left,
};
/// The task [payload] holds, opened; null for one of a newer schema or one /// The task [payload] holds, opened; null for one of a newer schema or one
/// it can't read: a task is never read half, so one whose date can't be /// it can't read: a task is never read half, so one whose date can't be
/// read isn't read at all. /// read isn't read at all.
@ -62,6 +81,10 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
final String at => DateTime.parse(at).toLocal(), final String at => DateTime.parse(at).toLocal(),
_ => null, _ => null,
}, },
completions: [
for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(),
],
), ),
); );
@ -78,5 +101,48 @@ TaskDate _date(Map<String, Object?> json) {
final int minutes => Duration(minutes: minutes), final int minutes => Duration(minutes: minutes),
_ => null, _ => null,
}, },
repeat: switch (json['repeat']) {
final Map<String, Object?> repeat => _repeat(repeat),
null => null,
_ => throw const FormatException('repeat'),
},
);
}
/// A recurrence as [_encodeRepeat] wrote it; throws for one it can't
/// follow, so that its task isn't read half.
Repeat _repeat(Map<String, Object?> json) {
final unit = RepeatUnit.values.byName(json['unit']! as String);
final every = json['every'] as int? ?? 1;
final weekdays = {
for (final day in json['weekdays'] as List<Object?>? ?? const [])
day! as int,
};
final monthDay = json['monthDay'] as int?;
final monthWeek = json['monthWeek'] as int?;
final left = json['left'] as int?;
final until = switch (json['until']) {
final String day => Day.tryParse(day) ?? (throw const FormatException()),
null => null,
_ => throw const FormatException('until'),
};
if (every < 1 ||
weekdays.any((day) => day < 1 || day > 7) ||
(monthDay != null && (monthDay < 1 || monthDay > 31)) ||
(monthWeek != null &&
monthWeek != -1 &&
(monthWeek < 1 || monthWeek > 4)) ||
(left != null && left < 0)) {
throw const FormatException('repeat');
}
return Repeat(
unit,
every: every,
weekdays: weekdays,
monthDay: monthDay,
monthWeek: monthWeek,
fromCompletion: json['fromCompletion'] as bool? ?? false,
until: until,
left: left,
); );
} }

View file

@ -33,13 +33,61 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
Future<void> update(String id, TaskContent content) => Future<void> update(String id, TaskContent content) =>
_rows.update(id, content); _rows.update(id, content);
/// Marks [task] done now, or not done where [done] is false. /// Marks [task] done now, or not done where [done] is false, and returns
Future<void> complete(StoredTask task, {bool done = true}) async { /// every task it changed as it was before, for [restore] to undo it.
if (task.content case final content?) { ///
/// A recurring task (ADR 0033, D7) done isn't marked done but moves on to
/// its next day, its deadline as far, its time kept, with when it was done
/// kept in its [TaskContent.completions], and its sub-tasks open again,
/// as Todoist's do; done its last time, it is done as any other.
Future<List<StoredTask>> complete(
StoredTask task, {
bool done = true,
}) async {
final content = task.content;
if (content == null) {
return const [];
}
final now = clock.now();
final date = content.date;
final repeat = date?.repeat;
final next = done && date != null && repeat != null
? repeat.next(date.day, today: Day.of(now))
: null;
if (date == null || repeat == null || next == null) {
await update( await update(
task.id, task.id,
content.copyWith(completedAt: () => done ? clock.now() : null), content.copyWith(completedAt: () => done ? now : null),
); );
return [task];
}
final moved = date.day.daysUntil(next);
final tasks = await all();
final below = _withDescendants({task.id}, tasks)..remove(task.id);
final reopened = [
for (final sub in tasks)
if (below.contains(sub.id) && (sub.content?.completed ?? false)) sub,
];
await update(
task.id,
content.copyWith(
date: () => date.on(next).repeating(repeat.counted()),
deadline: () => content.deadline?.plus(moved),
completions: [...content.completions, now],
),
);
for (final sub in reopened) {
await update(sub.id, sub.content!.copyWith(completedAt: () => null));
}
return [task, ...reopened];
}
/// Writes [tasks] back as they were, undoing what [complete] changed.
Future<void> restore(List<StoredTask> tasks) async {
for (final task in tasks) {
if (task.content case final content?) {
await update(task.id, content);
}
} }
} }

View file

@ -0,0 +1,181 @@
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// What a recurrence counts in (ADR 0033, D7).
enum RepeatUnit() {
day,
week,
month,
year,
}
/// A task's recurrence (ADR 0033, D7), a rule set through a picker: [every]
/// so many [unit]s, from its date or, where [fromCompletion], from the day
/// it was done; until a day, or for [left] more times.
final class const Repeat(
final RepeatUnit unit, {
final int every = 1,
/// The weekdays (`DateTime.monday` to `sunday`) a weekly one falls on;
/// none: its date's.
final Set<int> weekdays = const {},
/// The day of the month (1 to 31) a monthly or yearly one falls on, in a
/// shorter month its last; none: its date's.
final int? monthDay,
/// Where a monthly one falls on its date's weekday: the month's first to
/// fourth (1 to 4), or its last (-1); none: on [monthDay].
final int? monthWeek,
/// Counted from the day it was done, not from its date: always [every]
/// units on, on no particular weekday or day.
final bool fromCompletion = false,
/// The last day it may fall on.
final Day? until,
/// How many times more it comes after the one now due; none: endlessly.
final int? left,
}) with Equatable {
/// The day a task due on [date] moves on to when it is done on [today];
/// none where the recurrence has ended. Counted from its date, it is the
/// first after both: an overdue task isn't moved onto a day already
/// past, nor onto the day it was done.
Day? next(Day date, {required Day today}) {
if (left == 0) {
return null;
}
final Day next;
if (fromCompletion) {
next = _plain(today, today);
} else {
final after = date > today ? date : today;
var day = _after(date, date);
while (!(day > after)) {
day = _after(day, date);
}
next = day;
}
return switch (until) {
final last? when next > last => null,
_ => next,
};
}
/// This recurrence once one of its times has come: [left] one fewer.
Repeat counted() => switch (left) {
final left? when left > 0 => _with(left: left - 1),
_ => this,
};
/// The first day on or after [day] it falls on, for a task given it
/// anew: Monday for every weekday given on a Saturday.
Day firstFrom(Day day) {
if (fromCompletion) {
return day;
}
switch (unit) {
case RepeatUnit.week when weekdays.isNotEmpty:
var first = day;
while (!weekdays.contains(first.weekday)) {
first = first.plus(1);
}
return first;
case RepeatUnit.month when monthWeek != null:
final here = _nthWeekday(day.year, day.month, day.weekday, monthWeek!);
return here < day
? _nthWeekday(day.year, day.month + 1, day.weekday, monthWeek!)
: here;
case RepeatUnit.month when monthDay != null:
final here = _clamped(day.year, day.month, monthDay!);
return here < day ? _clamped(day.year, day.month + 1, monthDay!) : here;
case _:
return day;
}
}
/// The first day after [day] it falls on, for a recurrence that started
/// on [anchor] (whose week, weekday and day of the month it keeps).
Day _after(Day day, Day anchor) {
switch (unit) {
case RepeatUnit.week when weekdays.isNotEmpty:
final start = anchor.weekStart;
var next = day.plus(1);
while (!weekdays.contains(next.weekday) ||
(start.daysUntil(next.weekStart) ~/ 7) % every != 0) {
next = next.plus(1);
}
return next;
case RepeatUnit.month when monthWeek != null:
return _nthWeekday(
day.year,
day.month + every,
anchor.weekday,
monthWeek!,
);
case _:
return _plain(day, anchor);
}
}
/// [every] units after [day], on [anchor]'s day of the month (or
/// [monthDay]) for months and years.
Day _plain(Day day, Day anchor) => switch (unit) {
RepeatUnit.day => day.plus(every),
RepeatUnit.week => day.plus(7 * every),
RepeatUnit.month => _clamped(
day.year,
day.month + every,
fromCompletion ? day.day : monthDay ?? anchor.day,
),
RepeatUnit.year => _clamped(
day.year + every,
day.month,
fromCompletion ? day.day : monthDay ?? anchor.day,
),
};
/// The [day] of [month] (past December: of the next year's), or the
/// month's last day where it is shorter.
static Day _clamped(int year, int month, int day) {
final first = Day.of(DateTime(year, month));
return Day(first.year, first.month, min(day, first.daysInMonth));
}
/// The [nth] [weekday] of [month] (-1: its last).
static Day _nthWeekday(int year, int month, int weekday, int nth) {
final first = Day.of(DateTime(year, month));
if (nth > 0) {
return first.plus((weekday - first.weekday + 7) % 7 + 7 * (nth - 1));
}
final last = Day(first.year, first.month, first.daysInMonth);
return last.plus(-((last.weekday - weekday + 7) % 7));
}
Repeat _with({required int left}) => Repeat(
unit,
every: every,
weekdays: weekdays,
monthDay: monthDay,
monthWeek: monthWeek,
fromCompletion: fromCompletion,
until: until,
left: left,
);
@override
List<Object?> get props => [
unit,
every,
// Sorted, so that sets in any order compare equal.
[...weekdays]..sort(),
monthDay,
monthWeek,
fromCompletion,
until,
left,
];
}

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
/// A day of the calendar, as a task's date and deadline name it (ADR 0033, /// A day of the calendar, as a task's date and deadline name it (ADR 0033,
/// D6): the person's own, the same wherever they are. /// D6): the person's own, the same wherever they are.
@ -30,6 +31,21 @@ final class const Day(final int year, final int month, final int day)
/// The day [days] later (or earlier, below zero). /// The day [days] later (or earlier, below zero).
Day plus(int days) => Day.of(DateTime(year, month, day + days)); Day plus(int days) => Day.of(DateTime(year, month, day + days));
/// Its weekday, `DateTime.monday` (1) to `sunday` (7).
int get weekday => toDate().weekday;
/// The Monday of its week.
Day get weekStart => plus(1 - weekday);
/// How many days its month has.
int get daysInMonth => DateTime(year, month + 1, 0).day;
/// Which of its month's weekdays like it it is: 1 for the first.
int get weekOfMonth => (day - 1) ~/ 7 + 1;
/// Whether it is the last of its month's weekdays like it.
bool get lastWeekOfMonth => day + 7 > daysInMonth;
/// How many days [other] comes after this one. /// How many days [other] comes after this one.
int daysUntil(Day other) => DateTime.utc( int daysUntil(Day other) => DateTime.utc(
other.year, other.year,
@ -55,16 +71,25 @@ final class const Day(final int year, final int month, final int day)
/// When a task is to be done (ADR 0033, D6): a [day], and on it a [time] /// When a task is to be done (ADR 0033, D6): a [day], and on it a [time]
/// (minutes after midnight, the person's wall clock) with a [duration] /// (minutes after midnight, the person's wall clock) with a [duration]
/// where one is given. A time without its zone means the same hour wherever /// where one is given, and how it recurs, if it does ([repeat], D7). A time
/// the person is, as a paper diary has it; reminders (step 4) may add the /// without its zone means the same hour wherever the person is, as a paper
/// zone. /// diary has it; reminders (step 4) may add the zone.
final class const TaskDate( final class const TaskDate(
final Day day, { final Day day, {
final int? time, final int? time,
final Duration? duration, final Duration? duration,
final Repeat? repeat,
}) with Equatable { }) with Equatable {
/// The same on [day].
TaskDate on(Day day) =>
TaskDate(day, time: time, duration: duration, repeat: repeat);
/// The same, recurring as [repeat] (none: not).
TaskDate repeating(Repeat? repeat) =>
TaskDate(day, time: time, duration: duration, repeat: repeat);
@override @override
List<Object?> get props => [day, time, duration]; List<Object?> get props => [day, time, duration, repeat];
} }
/// How urgent a task is (D6): [p1] the most, [none] not marked. /// How urgent a task is (D6): [p1] the most, [none] not marked.
@ -116,6 +141,10 @@ final class const TaskContent({
/// When it was done; none: not yet. /// When it was done; none: not yet.
final DateTime? completedAt, final DateTime? completedAt,
/// When a recurring task's past times were done, the oldest first (D6),
/// for Reporting to count.
final List<DateTime> completions = const [],
}) with Equatable { }) with Equatable {
/// Whether it is done. /// Whether it is done.
bool get completed => completedAt != null; bool get completed => completedAt != null;
@ -132,6 +161,7 @@ final class const TaskContent({
Priority? priority, Priority? priority,
List<String>? labelIds, List<String>? labelIds,
DateTime? Function()? completedAt, DateTime? Function()? completedAt,
List<DateTime>? completions,
}) => TaskContent( }) => TaskContent(
title: title ?? this.title, title: title ?? this.title,
projectId: projectId ?? this.projectId, projectId: projectId ?? this.projectId,
@ -144,6 +174,7 @@ final class const TaskContent({
priority: priority ?? this.priority, priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds, labelIds: labelIds ?? this.labelIds,
completedAt: completedAt == null ? this.completedAt : completedAt(), completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions,
); );
@override @override
@ -159,6 +190,7 @@ final class const TaskContent({
priority, priority,
labelIds, labelIds,
completedAt, completedAt,
completions,
]; ];
} }

View file

@ -1,6 +1,8 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart'; import 'package:halkyon_todo_flutter/l10n/app_localizations.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_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -12,8 +14,8 @@ typedef DateChoice = ({TaskDate? date});
/// Asks for a task's date (ADR 0033, D8): today, tomorrow, the coming /// Asks for a task's date (ADR 0033, D8): today, tomorrow, the coming
/// weekend, next week (the day the language starts it on), a day from the /// weekend, next week (the day the language starts it on), a day from the
/// calendar, with a time where the person adds one; or none, where /// calendar, with a time where the person adds one, recurring as they
/// [current] has one. /// pick (D7, [pickRepeat]); or none, where [current] has one.
Future<DateChoice?> pickTaskDate( Future<DateChoice?> pickTaskDate(
BuildContext context, { BuildContext context, {
TaskDate? current, TaskDate? current,
@ -44,10 +46,12 @@ class const _DateSheet({required final TaskDate? current})
class _DateSheetState() extends State<_DateSheet> { class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time; late int? _time = widget.current?.time;
late final Duration? _duration = widget.current?.duration; late final Duration? _duration = widget.current?.duration;
late final Repeat? _repeat = widget.current?.repeat;
void _choose(Day day) => Navigator.of( void _choose(Day day, {Repeat? repeat}) =>
context, Navigator.of(context).pop<DateChoice>((
).pop<DateChoice>((date: TaskDate(day, time: _time, duration: _duration))); date: TaskDate(day, time: _time, duration: _duration, repeat: repeat),
));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -67,7 +71,7 @@ class _DateSheetState() extends State<_DateSheet> {
leading: Icon(icon), leading: Icon(icon),
title: Text(label), title: Text(label),
trailing: Text(dayText(context, day, today)), trailing: Text(dayText(context, day, today)),
onTap: () => _choose(day), onTap: () => _choose(day, repeat: _repeat),
); );
return SheetScaffold( return SheetScaffold(
title: l10n.dateTitle, title: l10n.dateTitle,
@ -90,7 +94,7 @@ class _DateSheetState() extends State<_DateSheet> {
lastDate: DateTime(today.year + 20), lastDate: DateTime(today.year + 20),
); );
if (picked != null && context.mounted) { if (picked != null && context.mounted) {
_choose(Day.of(picked)); _choose(Day.of(picked), repeat: _repeat);
} }
}, },
), ),
@ -120,6 +124,26 @@ class _DateSheetState() extends State<_DateSheet> {
} }
}, },
), ),
ListTile(
leading: const Icon(LucideIcons.repeat),
title: Text(l10n.repeatTitle),
subtitle: switch (_repeat) {
final repeat? => Text(
repeatText(context, repeat, widget.current?.day ?? today),
),
null => null,
},
onTap: () async {
final day = widget.current?.day ?? today;
if (await pickRepeat(context, day: day, current: _repeat)
case (
:final repeat,
)
when context.mounted) {
_choose(repeat?.firstFrom(day) ?? day, repeat: repeat);
}
},
),
if (widget.current != null) if (widget.current != null)
ListTile( ListTile(
leading: const Icon(LucideIcons.calendarX), leading: const Icon(LucideIcons.calendarX),

View file

@ -0,0 +1,413 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A recurrence chosen ([Repeat] or none, to stop it); null from
/// [pickRepeat] where the person changed their mind.
typedef RepeatChoice = ({Repeat? repeat});
/// [repeat] in words (ADR 0033, D7), for a task due on [day]: "Every 2
/// weeks on Mon, Thu, until 1 Mar".
String repeatText(BuildContext context, Repeat repeat, Day day) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final every = repeat.every;
final workdays = {for (var d = 1; d <= 5; d++) d};
var rule = switch (repeat.unit) {
RepeatUnit.week
when every == 1 &&
repeat.weekdays.length == 5 &&
repeat.weekdays.containsAll(workdays) =>
l10n.repeatWeekdays,
RepeatUnit.day => l10n.repeatEveryDay(every),
RepeatUnit.week => l10n.repeatEveryWeek(every),
RepeatUnit.month => l10n.repeatEveryMonth(every),
RepeatUnit.year => l10n.repeatEveryYear(every),
};
if (!repeat.fromCompletion) {
final on = switch (repeat.unit) {
RepeatUnit.week when rule == l10n.repeatWeekdays => null,
RepeatUnit.week => [
for (final weekday
in repeat.weekdays.isEmpty
? [day.weekday]
: ([...repeat.weekdays]..sort()))
weekdayName(locale, weekday, short: true),
].join(', '),
RepeatUnit.month => monthOn(context, repeat, day),
RepeatUnit.year => DateFormat.MMMMd(locale).format(
Day(day.year, day.month, repeat.monthDay ?? day.day).toDate(),
),
RepeatUnit.day => null,
};
if (on != null) {
rule = l10n.repeatOn(rule, on);
}
} else {
rule = l10n.repeatFromCompletion(rule);
}
if (repeat.until case final until?) {
rule = l10n.repeatUntil(rule, dayText(context, until, Day.of(clock.now())));
}
if (repeat.left case final left?) {
rule = l10n.repeatLeft(rule, left);
}
return rule;
}
/// Where a monthly [repeat] of a task due on [day] falls: "day 15", "the
/// last Friday".
String monthOn(BuildContext context, Repeat repeat, Day day) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
return switch (repeat.monthWeek) {
final nth? => l10n.repeatMonthWeek(
nth < 0 ? 'last' : '$nth',
weekdayName(locale, day.weekday),
),
null => l10n.repeatMonthDay(repeat.monthDay ?? day.day),
};
}
/// The name of [weekday] (`DateTime.monday` to `sunday`) in [locale].
String weekdayName(String locale, int weekday, {bool short = false}) =>
(short ? DateFormat.E(locale) : DateFormat.EEEE(locale))
// 5 Jan 2026 was a Monday.
.format(DateTime(2026, 1, 4 + weekday));
/// Asks how a task due on [day] recurs (ADR 0033, D7): the rules that fit
/// its day (every day, every weekday, every week on its weekday, every
/// month on its day or its weekday, every year), a rule of the person's
/// own, or none where [current] is one.
Future<RepeatChoice?> pickRepeat(
BuildContext context, {
required Day day,
Repeat? current,
}) => showAdaptiveSheet<RepeatChoice>(
context,
builder: (context) => _RepeatSheet(day: day, current: current),
);
class const _RepeatSheet({required final Day day, final Repeat? current})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
void choose(Repeat? repeat) =>
Navigator.of(context).pop<RepeatChoice>((repeat: repeat));
final options = [
const Repeat(RepeatUnit.day),
const Repeat(
RepeatUnit.week,
weekdays: {
DateTime.monday,
DateTime.tuesday,
DateTime.wednesday,
DateTime.thursday,
DateTime.friday,
},
),
Repeat(RepeatUnit.week, weekdays: {day.weekday}),
Repeat(RepeatUnit.month, monthDay: day.day),
Repeat(
RepeatUnit.month,
monthWeek: day.lastWeekOfMonth && day.weekOfMonth > 4
? -1
: day.weekOfMonth,
),
Repeat(RepeatUnit.year, monthDay: day.day),
];
return SheetScaffold(
title: l10n.repeatTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final option in options)
ListTile(
leading: Icon(
option == current ? LucideIcons.check : LucideIcons.repeat,
),
title: Text(repeatText(context, option, day)),
onTap: () => choose(option),
),
ListTile(
leading: const Icon(LucideIcons.slidersHorizontal),
title: Text(l10n.repeatCustom),
subtitle: current == null || options.contains(current)
? null
: Text(repeatText(context, current!, day)),
onTap: () async {
final custom = await showAdaptiveSheet<Repeat>(
context,
builder: (context) => _RepeatEditor(day: day, current: current),
);
if (custom != null && context.mounted) {
choose(custom);
}
},
),
if (current != null)
ListTile(
leading: const Icon(LucideIcons.repeat1),
title: Text(l10n.repeatNone),
onTap: () => choose(null),
),
],
),
);
}
}
enum _Ends() {
never,
on,
after,
}
/// A rule of the person's own: every n units, on which weekdays or which
/// day of the month, from the date or from when it is done, and when it
/// ends.
class const _RepeatEditor({required final Day day, final Repeat? current})
extends StatefulWidget {
@override
State<_RepeatEditor> createState() => _RepeatEditorState();
}
class _RepeatEditorState() extends State<_RepeatEditor> {
late RepeatUnit _unit = widget.current?.unit ?? RepeatUnit.week;
late int _every = widget.current?.every ?? 1;
late Set<int> _weekdays = switch (widget.current?.weekdays) {
final days? when days.isNotEmpty => {...days},
_ => {widget.day.weekday},
};
late bool _byWeekday = widget.current?.monthWeek != null;
late bool _fromCompletion = widget.current?.fromCompletion ?? false;
late _Ends _ends = switch (widget.current) {
Repeat(until: _?) => _Ends.on,
Repeat(left: _?) => _Ends.after,
_ => _Ends.never,
};
late Day _until = widget.current?.until ?? widget.day.plus(30);
late int _times = (widget.current?.left ?? 9) + 1;
Repeat get _repeat {
final day = widget.day;
return Repeat(
_unit,
every: _every,
weekdays: _unit == RepeatUnit.week && !_fromCompletion ? _weekdays : {},
monthDay: switch (_unit) {
RepeatUnit.month when !_byWeekday => day.day,
RepeatUnit.year => day.day,
_ => null,
},
monthWeek: _unit == RepeatUnit.month && _byWeekday
? (day.lastWeekOfMonth && day.weekOfMonth > 4 ? -1 : day.weekOfMonth)
: null,
fromCompletion: _fromCompletion,
until: _ends == _Ends.on ? _until : null,
left: _ends == _Ends.after ? _times - 1 : null,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
final day = widget.day;
Widget stepper(int value, int min, ValueChanged<int> changed) => Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(LucideIcons.minus),
onPressed: value > min ? () => changed(value - 1) : null,
),
SizedBox(
width: 32,
child: Text('$value', textAlign: TextAlign.center),
),
IconButton(
icon: const Icon(LucideIcons.plus),
onPressed: value < 99 ? () => changed(value + 1) : null,
),
],
);
return SheetScaffold(
title: l10n.repeatCustom,
footer: Padding(
padding: const EdgeInsets.all(16),
child: Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed:
_unit == RepeatUnit.week &&
!_fromCompletion &&
_weekdays.isEmpty
? null
: () => Navigator.of(context).pop(_repeat),
child: Text(l10n.save),
),
),
),
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(repeatText(context, _repeat, day)),
),
ListTile(
title: Text(l10n.repeatEvery),
trailing: stepper(_every, 1, (v) => setState(() => _every = v)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<RepeatUnit>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: RepeatUnit.day,
label: Text(l10n.repeatUnitDays),
),
ButtonSegment(
value: RepeatUnit.week,
label: Text(l10n.repeatUnitWeeks),
),
ButtonSegment(
value: RepeatUnit.month,
label: Text(l10n.repeatUnitMonths),
),
ButtonSegment(
value: RepeatUnit.year,
label: Text(l10n.repeatUnitYears),
),
],
selected: {_unit},
onSelectionChanged: (unit) => setState(() => _unit = unit.single),
),
),
if (_unit == RepeatUnit.week && !_fromCompletion)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < 7; i++)
// Material counts from 0 (Sunday), DateTime from 1
// (Monday) to 7 (Sunday).
if ((firstDay + i - 1) % 7 + 1 case final weekday)
FilterChip(
label: Text(weekdayName(locale, weekday, short: true)),
selected: _weekdays.contains(weekday),
onSelected: (on) => setState(
() => _weekdays = on
? {..._weekdays, weekday}
: ({..._weekdays}..remove(weekday)),
),
),
],
),
),
if (_unit == RepeatUnit.month && !_fromCompletion)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<bool>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: false,
label: Text(
monthOn(context, const Repeat(RepeatUnit.month), day),
),
),
ButtonSegment(
value: true,
label: Text(
monthOn(
context,
Repeat(
RepeatUnit.month,
monthWeek: day.lastWeekOfMonth && day.weekOfMonth > 4
? -1
: day.weekOfMonth,
),
day,
),
),
),
],
selected: {_byWeekday},
onSelectionChanged: (by) =>
setState(() => _byWeekday = by.single),
),
),
SwitchListTile(
title: Text(l10n.repeatCountFromCompletion),
subtitle: Text(l10n.repeatCountFromCompletionHint),
value: _fromCompletion,
onChanged: (on) => setState(() => _fromCompletion = on),
),
ListTile(title: Text(l10n.repeatEnds)),
RadioGroup<_Ends>(
groupValue: _ends,
onChanged: (ends) => setState(() => _ends = ends!),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile(
value: _Ends.never,
title: Text(l10n.repeatEndsNever),
),
RadioListTile(
value: _Ends.on,
title: Text(l10n.repeatEndsOn),
secondary: TextButton(
onPressed: () async {
final today = Day.of(clock.now());
final picked = await showDatePicker(
context: context,
initialDate: _until.toDate(),
firstDate: today.toDate(),
lastDate: DateTime(today.year + 20),
);
if (picked != null) {
setState(() {
_until = Day.of(picked);
_ends = _Ends.on;
});
}
},
child: Text(
DateFormat.yMMMd(locale).format(_until.toDate()),
),
),
),
RadioListTile(
value: _Ends.after,
title: Text(l10n.repeatEndsAfter(_times)),
secondary: stepper(
_times,
1,
(v) => setState(() {
_times = v;
_ends = _Ends.after;
}),
),
),
],
),
),
],
),
);
}
}

View file

@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
@ -214,6 +215,12 @@ class _TaskSheetState() extends State<_TaskSheet> {
final date? => dateText(context, date, today), final date? => dateText(context, date, today),
null => l10n.dateNoneYet, null => l10n.dateNoneYet,
}), }),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null trailing: content.date == null
? null ? null
: clear(() => _change((c) => c.copyWith(date: () => null))), : clear(() => _change((c) => c.copyWith(date: () => null))),

View file

@ -50,7 +50,7 @@ class const TaskTile({
final details = [ final details = [
if (content.date case final date?) if (content.date case final date?)
meta( meta(
LucideIcons.calendar, date.repeat == null ? LucideIcons.calendar : LucideIcons.repeat,
dateText(context, date, today), dateText(context, date, today),
dateColor(context, date.day, today, done: content.completed), dateColor(context, date.day, today, done: content.completed),
), ),
@ -136,12 +136,12 @@ class const TaskCheck({required final OpenTask task, super.key})
onPressed: () async { onPressed: () async {
final tasks = PersonScope.of<TodoPerson>(context).tasks; final tasks = PersonScope.of<TodoPerson>(context).tasks;
final stored = StoredTask(task.id, content); final stored = StoredTask(task.id, content);
await tasks.complete(stored, done: !content.completed); final before = await tasks.complete(stored, done: !content.completed);
if (!content.completed && context.mounted) { if (!content.completed && context.mounted) {
showUndoSnackBar( showUndoSnackBar(
context, context,
message: l10n.taskCompleted, message: l10n.taskCompleted,
onUndo: () => unawaited(tasks.update(task.id, content)), onUndo: () => unawaited(tasks.restore(before)),
); );
} }
}, },

View file

@ -130,6 +130,7 @@ Future<void> reschedule(BuildContext context, List<OpenTask> tasks) async {
date.day, date.day,
time: date.time ?? old?.time, time: date.time ?? old?.time,
duration: old?.duration, duration: old?.duration,
repeat: old?.repeat,
), ),
), ),
); );

View file

@ -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/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_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/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_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../support/memory_content_key_gateway.dart'; import '../support/memory_content_key_gateway.dart';
@ -382,6 +384,54 @@ void main() {
expect(title.style?.decoration, TextDecoration.lineThrough); 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 ' testWidgets('Today puts what is overdue first; Reschedule moves it to '
'the day picked', (tester) async { 'the day picked', (tester) async {
final today = Day.of(DateTime.now()); final today = Day.of(DateTime.now());

View file

@ -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);
});
}

View file

@ -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/data/section_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.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/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_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -31,11 +32,18 @@ void main() {
Day(2026, 11, 2), Day(2026, 11, 2),
time: 6 * 60 + 30, time: 6 * 60 + 30,
duration: Duration(minutes: 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), deadline: const Day(2026, 11, 8),
priority: Priority.p2, priority: Priority.p2,
labelIds: const ['l1', 'l2'], labelIds: const ['l1', 'l2'],
completedAt: DateTime(2026, 11, 2, 7), completedAt: DateTime(2026, 11, 2, 7),
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
); );
group('a task', () { group('a task', () {
@ -55,11 +63,25 @@ void main() {
'sectionId': 's', 'sectionId': 's',
'parentId': 't', 'parentId': 't',
'position': 3.0, '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', 'deadline': '2026-11-08',
'priority': 2, 'priority': 2,
'labelIds': ['l1', 'l2'], 'labelIds': ['l1', 'l2'],
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(), '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( expect(
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))), written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
@ -89,6 +111,26 @@ void main() {
'date': {'day': '2026-11-02', 'time': 1440}, 'date': {'day': '2026-11-02', 'time': 1440},
}, },
{...base, 'deadline': 'soon'}, {...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'}, {...base, 'date': 'today'},
]) { ]) {
expect(decodeTask(json(broken)), isNull, reason: '$broken'); expect(decodeTask(json(broken)), isNull, reason: '$broken');
@ -96,6 +138,23 @@ void main() {
expect(decodeTask(ByteData(0)), isNull); 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', () { test('a priority it doesn’t know is none', () {
expect( expect(
decodeTask( decodeTask(

View file

@ -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/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.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/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_todo_flutter/src/feature/task/model/task.dart';
import '../../support/memory_content_key_gateway.dart'; import '../../support/memory_content_key_gateway.dart';
@ -80,6 +81,79 @@ void main() {
expect(await phone.all(), isEmpty); 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( test(
'deleting a task deletes its sub-tasks and theirs, and no other', 'deleting a task deletes its sub-tasks and theirs, and no other',
() async { () async {

View file

@ -1,8 +1,8 @@
# ADR 0033: Halkyon Todo, what it does and how it looks # ADR 0033: Halkyon Todo, what it does and how it looks
- **Status:** decided 1 Nov 2026, by the developer (D1–D10 below, the agent's - **Status:** decided 1 Nov 2026, by the developer (D1–D10 below, the agent's
recommendations taken as a whole). Built the same day: steps 1 to 3 and Today and recommendations taken as a whole). Built the same day: steps 1 to 3, Today and
Upcoming as lists (step 4 without recurrence, reminders and the week strip). Upcoming as lists, and recurrence (step 4 without reminders and the week strip).
- **Numbering:** 0025–0029 are held (ADR 0030's note), 0032 is Jellyfin's; this is the - **Numbering:** 0025–0029 are held (ADR 0030's note), 0032 is Jellyfin's; this is the
next free number. next free number.
- **Builds on:** ADR 0030, D7, D12, D14, D15 and D60 (Train's sealed rows, its shell, its - **Builds on:** ADR 0030, D7, D12, D14, D15 and D60 (Train's sealed rows, its shell, its
@ -102,6 +102,17 @@ the rail, a wash of strawberry, settings grouped as theirs. What the screenshots
or the nth weekday), years, from the date or from completion, until a day or for n or the nth weekday), years, from the date or from completion, until a day or for n
times. Dates and tokens typed into quick add ("tomorrow 6am", "every Monday", `#project`, times. Dates and tokens typed into quick add ("tomorrow 6am", "every Monday", `#project`,
`p1`), in English and German, are a later step, built on the same rule. `p1`), in English and German, are a later step, built on the same rule.
As built (1 Nov): the rule is part of the task's date (`Repeat` in `TaskDate`, its
payload's `date.repeat`), so a task without a date doesn't recur and clearing the date
ends it; schema 1 holds it, as no task was written before. Done, a task counted from its
date moves to the rule's first day after both its date and today (an overdue one isn't
moved onto a day already past), counted from completion it moves the rule's units on
from today, on no particular weekday; its deadline moves as far, its time is kept, its
done sub-tasks open again, and when it was done is added to its `completions`. Undo
writes back every task that changed. The picker offers the rules that fit its day
(every day, every weekday, its weekday, its day or nth weekday of the month, its day of
the year) and a rule of the person's own; weeks start on Monday for every n weeks, until
the week's first day is a setting (D4).
- **D8 The first version** (recommended): Inbox and projects with sections, shown as a - **D8 The first version** (recommended): Inbox and projects with sections, shown as a
list; sub-tasks; Today (Overdue first, rescheduling them together, then today's); Upcoming list; sub-tasks; Today (Overdue first, rescheduling them together, then today's); Upcoming
as an agenda with the week strip opening into the month; the task sheet (rows for the as an agenda with the week strip opening into the month; the task sheet (rows for the