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
18 changed files with 1655 additions and 25 deletions

View file

@ -94,5 +94,30 @@
"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.}}",
"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": {"placeholders": {"count": {"type": "int"}}},
"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:
/// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'**
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

View file

@ -322,4 +322,149 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get inboxMissingHint =>
'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
String get inboxMissingHint =>
'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`).

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
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';
/// 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
/// `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, {
'title': content.title,
'projectId': content.projectId,
@ -21,14 +24,30 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
'day': '${date.day}',
'time': ?date.time,
'minutes': ?date.duration?.inMinutes,
if (date.repeat case final repeat?) 'repeat': _encodeRepeat(repeat),
},
if (content.deadline case final deadline?) 'deadline': '$deadline',
if (content.priority != Priority.none) 'priority': content.priority.number,
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
if (content.completedAt case final at?)
'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
/// it can't read: a task is never read half, so one whose date can't be
/// read isn't read at all.
@ -62,6 +81,10 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
final String at => DateTime.parse(at).toLocal(),
_ => 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),
_ => 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) =>
_rows.update(id, content);
/// Marks [task] done now, or not done where [done] is false.
Future<void> complete(StoredTask task, {bool done = true}) async {
if (task.content case final content?) {
/// Marks [task] done now, or not done where [done] is false, and returns
/// every task it changed as it was before, for [restore] to undo it.
///
/// 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(
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: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,
/// 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).
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.
int daysUntil(Day other) => DateTime.utc(
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]
/// (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
/// the person is, as a paper diary has it; reminders (step 4) may add the
/// zone.
/// where one is given, and how it recurs, if it does ([repeat], D7). A time
/// without its zone means the same hour wherever the person is, as a paper
/// diary has it; reminders (step 4) may add the zone.
final class const TaskDate(
final Day day, {
final int? time,
final Duration? duration,
final Repeat? repeat,
}) 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
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.
@ -116,6 +141,10 @@ final class const TaskContent({
/// When it was done; none: not yet.
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 {
/// Whether it is done.
bool get completed => completedAt != null;
@ -132,6 +161,7 @@ final class const TaskContent({
Priority? priority,
List<String>? labelIds,
DateTime? Function()? completedAt,
List<DateTime>? completions,
}) => TaskContent(
title: title ?? this.title,
projectId: projectId ?? this.projectId,
@ -144,6 +174,7 @@ final class const TaskContent({
priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds,
completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions,
);
@override
@ -159,6 +190,7 @@ final class const TaskContent({
priority,
labelIds,
completedAt,
completions,
];
}

View file

@ -1,6 +1,8 @@
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/repeat_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.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
/// 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
/// [current] has one.
/// calendar, with a time where the person adds one, recurring as they
/// pick (D7, [pickRepeat]); or none, where [current] has one.
Future<DateChoice?> pickTaskDate(
BuildContext context, {
TaskDate? current,
@ -44,10 +46,12 @@ class const _DateSheet({required final TaskDate? current})
class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time;
late final Duration? _duration = widget.current?.duration;
late final Repeat? _repeat = widget.current?.repeat;
void _choose(Day day) => Navigator.of(
context,
).pop<DateChoice>((date: TaskDate(day, time: _time, duration: _duration)));
void _choose(Day day, {Repeat? repeat}) =>
Navigator.of(context).pop<DateChoice>((
date: TaskDate(day, time: _time, duration: _duration, repeat: repeat),
));
@override
Widget build(BuildContext context) {
@ -67,7 +71,7 @@ class _DateSheetState() extends State<_DateSheet> {
leading: Icon(icon),
title: Text(label),
trailing: Text(dayText(context, day, today)),
onTap: () => _choose(day),
onTap: () => _choose(day, repeat: _repeat),
);
return SheetScaffold(
title: l10n.dateTitle,
@ -90,7 +94,7 @@ class _DateSheetState() extends State<_DateSheet> {
lastDate: DateTime(today.year + 20),
);
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)
ListTile(
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/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/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_texts.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),
null => l10n.dateNoneYet,
}),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),

View file

@ -50,7 +50,7 @@ class const TaskTile({
final details = [
if (content.date case final date?)
meta(
LucideIcons.calendar,
date.repeat == null ? LucideIcons.calendar : LucideIcons.repeat,
dateText(context, date, today),
dateColor(context, date.day, today, done: content.completed),
),
@ -136,12 +136,12 @@ class const TaskCheck({required final OpenTask task, super.key})
onPressed: () async {
final tasks = PersonScope.of<TodoPerson>(context).tasks;
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) {
showUndoSnackBar(
context,
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,
time: date.time ?? old?.time,
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/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());

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/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(

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/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 {