feat(todo): reminders on the device, Upcoming's week strip, the task pane and search (ADR 0033, D2, D5, D8)

A task with a time takes reminders, minutes before it, in its sealed
payload; each device plans the coming ones whenever the tasks change and
schedules them with the system through flutter_local_notifications
(Android, iOS, macOS, Windows; on Linux the running app shows them), at
the minute where Android allows exact alarms. Notifying is asked for
when a task first gets a reminder; signing out or switching person
cancels them. Upcoming gets its week strip: the month opening
Material's calendar, the weeks before and after, a dot under each day
with tasks; a day picked scrolls the agenda to it, and scrolling moves
the strip along. From expanded windows on, a task opens in a pane
beside the list rather than in a dialog. Search, from every tab's app
bar, finds projects by name and tasks by every word typed, in their
title, description or labels, whatever the case and accents, on the
device alone.

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:16:19 +01:00
commit 666e8dbcbb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
21 changed files with 2140 additions and 57 deletions

View file

@ -119,5 +119,23 @@
"repeatEnds": "Endet",
"repeatEndsNever": "Nie",
"repeatEndsOn": "An einem Tag",
"repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}"
"repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}",
"remindersTitle": "Erinnerungen",
"remindersNone": "Keine Erinnerungen",
"reminderAtTime": "Zur Uhrzeit",
"reminderMinutesBefore": "{count, plural, =1{1 Minute vorher} other{{count} Minuten vorher}}",
"reminderHoursBefore": "{count, plural, =1{1 Stunde vorher} other{{count} Stunden vorher}}",
"reminderDaysBefore": "{count, plural, =1{1 Tag vorher} other{{count} Tage vorher}}",
"reminderDue": "Fällig um {time}",
"remindersChannel": "Erinnerungen an Aufgaben",
"remindersNotAllowed": "Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.",
"remindersOnThisDevice": "Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.",
"weekPrevious": "Vorige Woche",
"weekNext": "Folgende Woche",
"searchTitle": "Suchen",
"searchHint": "Aufgaben und Projekte suchen",
"searchEmpty": "Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.",
"searchNone": "Nichts gefunden für „{query}“.",
"searchProjects": "Projekte",
"searchTasks": "Aufgaben"
}

View file

@ -144,5 +144,28 @@
"repeatEndsNever": "Never",
"repeatEndsOn": "On a day",
"repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}",
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}}
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}},
"remindersTitle": "Reminders",
"remindersNone": "No reminders",
"reminderAtTime": "At its time",
"reminderMinutesBefore": "{count, plural, =1{1 minute before} other{{count} minutes before}}",
"@reminderMinutesBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderHoursBefore": "{count, plural, =1{1 hour before} other{{count} hours before}}",
"@reminderHoursBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderDaysBefore": "{count, plural, =1{1 day before} other{{count} days before}}",
"@reminderDaysBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderDue": "Due at {time}",
"@reminderDue": {"placeholders": {"time": {"type": "String"}}},
"remindersChannel": "Task reminders",
"remindersNotAllowed": "Notifications are off for Halkyon Todo: reminders show once you allow them in the system's settings.",
"remindersOnThisDevice": "Reminders come on each of your devices that opens your tasks.",
"weekPrevious": "Previous week",
"weekNext": "Following week",
"searchTitle": "Search",
"searchHint": "Search tasks and projects",
"searchEmpty": "Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can't read your tasks.",
"searchNone": "Nothing found for “{query}”.",
"@searchNone": {"placeholders": {"query": {"type": "String"}}},
"searchProjects": "Projects",
"searchTasks": "Tasks"
}

View file

@ -783,6 +783,114 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{After once} other{After {count} times}}'**
String repeatEndsAfter(int count);
/// No description provided for @remindersTitle.
///
/// In en, this message translates to:
/// **'Reminders'**
String get remindersTitle;
/// No description provided for @remindersNone.
///
/// In en, this message translates to:
/// **'No reminders'**
String get remindersNone;
/// No description provided for @reminderAtTime.
///
/// In en, this message translates to:
/// **'At its time'**
String get reminderAtTime;
/// No description provided for @reminderMinutesBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 minute before} other{{count} minutes before}}'**
String reminderMinutesBefore(int count);
/// No description provided for @reminderHoursBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour before} other{{count} hours before}}'**
String reminderHoursBefore(int count);
/// No description provided for @reminderDaysBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 day before} other{{count} days before}}'**
String reminderDaysBefore(int count);
/// No description provided for @reminderDue.
///
/// In en, this message translates to:
/// **'Due at {time}'**
String reminderDue(String time);
/// No description provided for @remindersChannel.
///
/// In en, this message translates to:
/// **'Task reminders'**
String get remindersChannel;
/// No description provided for @remindersNotAllowed.
///
/// In en, this message translates to:
/// **'Notifications are off for Halkyon Todo: reminders show once you allow them in the system\'s settings.'**
String get remindersNotAllowed;
/// No description provided for @remindersOnThisDevice.
///
/// In en, this message translates to:
/// **'Reminders come on each of your devices that opens your tasks.'**
String get remindersOnThisDevice;
/// No description provided for @weekPrevious.
///
/// In en, this message translates to:
/// **'Previous week'**
String get weekPrevious;
/// No description provided for @weekNext.
///
/// In en, this message translates to:
/// **'Following week'**
String get weekNext;
/// No description provided for @searchTitle.
///
/// In en, this message translates to:
/// **'Search'**
String get searchTitle;
/// No description provided for @searchHint.
///
/// In en, this message translates to:
/// **'Search tasks and projects'**
String get searchHint;
/// No description provided for @searchEmpty.
///
/// In en, this message translates to:
/// **'Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can\'t read your tasks.'**
String get searchEmpty;
/// No description provided for @searchNone.
///
/// In en, this message translates to:
/// **'Nothing found for “{query}”.'**
String searchNone(String query);
/// No description provided for @searchProjects.
///
/// In en, this message translates to:
/// **'Projects'**
String get searchProjects;
/// No description provided for @searchTasks.
///
/// In en, this message translates to:
/// **'Tasks'**
String get searchTasks;
}
class _AppLocalizationsDelegate

View file

@ -467,4 +467,89 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get remindersTitle => 'Erinnerungen';
@override
String get remindersNone => 'Keine Erinnerungen';
@override
String get reminderAtTime => 'Zur Uhrzeit';
@override
String reminderMinutesBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Minuten vorher',
one: '1 Minute vorher',
);
return '$_temp0';
}
@override
String reminderHoursBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Stunden vorher',
one: '1 Stunde vorher',
);
return '$_temp0';
}
@override
String reminderDaysBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Tage vorher',
one: '1 Tag vorher',
);
return '$_temp0';
}
@override
String reminderDue(String time) {
return 'Fällig um $time';
}
@override
String get remindersChannel => 'Erinnerungen an Aufgaben';
@override
String get remindersNotAllowed =>
'Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.';
@override
String get remindersOnThisDevice =>
'Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.';
@override
String get weekPrevious => 'Vorige Woche';
@override
String get weekNext => 'Folgende Woche';
@override
String get searchTitle => 'Suchen';
@override
String get searchHint => 'Aufgaben und Projekte suchen';
@override
String get searchEmpty =>
'Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.';
@override
String searchNone(String query) {
return 'Nichts gefunden für „$query“.';
}
@override
String get searchProjects => 'Projekte';
@override
String get searchTasks => 'Aufgaben';
}

View file

@ -467,6 +467,91 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get remindersTitle => 'Reminders';
@override
String get remindersNone => 'No reminders';
@override
String get reminderAtTime => 'At its time';
@override
String reminderMinutesBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes before',
one: '1 minute before',
);
return '$_temp0';
}
@override
String reminderHoursBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours before',
one: '1 hour before',
);
return '$_temp0';
}
@override
String reminderDaysBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count days before',
one: '1 day before',
);
return '$_temp0';
}
@override
String reminderDue(String time) {
return 'Due at $time';
}
@override
String get remindersChannel => 'Task reminders';
@override
String get remindersNotAllowed =>
'Notifications are off for Halkyon Todo: reminders show once you allow them in the system\'s settings.';
@override
String get remindersOnThisDevice =>
'Reminders come on each of your devices that opens your tasks.';
@override
String get weekPrevious => 'Previous week';
@override
String get weekNext => 'Following week';
@override
String get searchTitle => 'Search';
@override
String get searchHint => 'Search tasks and projects';
@override
String get searchEmpty =>
'Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can\'t read your tasks.';
@override
String searchNone(String query) {
return 'Nothing found for “$query”.';
}
@override
String get searchProjects => 'Projects';
@override
String get searchTasks => 'Tasks';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,6 +5,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_scheduler.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -105,7 +106,8 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
],
child: ScreenColor(
color: PaletteColor.strawberry.background,
child: child!,
// Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
),
),
),

View file

@ -13,6 +13,8 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/sync_section_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
@ -48,6 +50,10 @@ class TodoPerson(
/// Their labels, sealed and synced.
required final LabelRepository labels,
/// The device's notifications, where their tasks' reminders go (ADR
/// 0033, D5); none in tests that don't say.
final ReminderGateway reminders = const NoReminders(),
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Deletes the project [id] with its sections and tasks; throws
@ -67,9 +73,12 @@ class TodoPerson(
await sections.delete([id]);
}
/// Closes what was opened for the person.
/// Closes what was opened for the person, and cancels their reminders:
/// a person signed out, or another signed in, isn't reminded of their
/// tasks on this device (the next person's are planned anew).
@override
Future<void> dispose() async {
await reminders.replace(const [], channel: '');
await _onDispose?.call();
}
}
@ -237,6 +246,7 @@ class TodoDependencies({
// One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId),
reminders: device.reminders,
onClosed: closed.complete,
);
} on Object {
@ -273,6 +283,7 @@ Future<TodoPerson> _openPersonData(
SignedIn person,
Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys,
required ReminderGateway reminders,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -323,6 +334,7 @@ Future<TodoPerson> _openPersonData(
sections: sections,
tasks: tasks,
labels: labels,
reminders: reminders,
onDispose: () async {
try {
await projects.dispose();
@ -355,6 +367,9 @@ class TodoDevice({
/// This build's version (before the `+`).
final String appVersion = '',
/// The system's notifications, where reminders go (ADR 0033, D5).
final ReminderGateway reminders = const NoReminders(),
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<TodoDevice> create({required Instance builtIn}) async {
@ -365,6 +380,19 @@ class TodoDevice({
storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,
reminders: await _reminders(),
);
}
}
/// The system's notifications, or none where they don't start (a desktop
/// without a notification service, say): the app works on without
/// reminders.
Future<ReminderGateway> _reminders() async {
try {
return await SystemReminderGateway.create(appName: 'Halkyon Todo');
} on Object catch (error) {
log('No reminders on this device: $error', name: 'Reminders');
return const NoReminders();
}
}

View file

@ -0,0 +1,28 @@
/// A reminder as the system shows it (ADR 0033, D5): its notification's
/// `id`, when, and its words.
typedef ReminderNotice = ({int id, DateTime at, String title, String body});
/// Where the device's reminders go: the system's notifications, scheduled
/// on the device alone, since the instance can read no task (D5).
abstract interface class ReminderGateway() {
/// Makes [notices] the reminders scheduled, those scheduled before and no
/// longer among them cancelled; [channel] names their kind where the
/// system lists it (Android's notification channels).
Future<void> replace(List<ReminderNotice> notices, {required String channel});
/// Asks the person to let the app show notifications, where the system
/// asks; whether it may.
Future<bool> askPermission();
}
/// No reminders at all: a device or test without notifications.
class const NoReminders() implements ReminderGateway {
@override
Future<void> replace(
List<ReminderNotice> notices, {
required String channel,
}) async {}
@override
Future<bool> askPermission() async => false;
}

View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
import 'package:timezone/timezone.dart' as tz;
/// The system's notifications through `flutter_local_notifications` (ADR
/// 0033, D5): scheduled with the system on Android, iOS, macOS and Windows,
/// so they come while the app is closed; on Linux, which schedules none,
/// shown by the running app alone.
///
/// Each is scheduled at its instant (in UTC): a task's time is its wall
/// clock, and the app plans again whenever it runs, so a device moved to
/// another zone has its reminders moved with it once the app is opened.
class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
implements ReminderGateway {
final _timers = <int, Timer>{};
/// Starts the plugin; [appName] and the Windows IDs name the app where the
/// system shows whose a notification is.
static Future<SystemReminderGateway> create({required String appName}) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Asked for when the person first adds a reminder, not at start.
iOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
macOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
linux: const LinuxInitializationSettings(defaultActionName: 'Open'),
windows: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Halkyon.Todo',
// Fixed for good: Windows knows the app's notifications by it.
guid: 'b6f1a0c2-6f0e-4d0a-9a51-3c1f2e7d8a41',
),
),
);
return SystemReminderGateway(plugin);
}
bool get _linux => defaultTargetPlatform == TargetPlatform.linux;
@override
Future<void> replace(
List<ReminderNotice> notices, {
required String channel,
}) async {
final details = NotificationDetails(
android: AndroidNotificationDetails(
'reminders',
channel,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.reminder,
// Its title is the task's: not on a locked screen.
visibility: NotificationVisibility.private,
),
iOS: const DarwinNotificationDetails(),
macOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(),
);
final ids = {for (final notice in notices) notice.id};
if (_linux) {
_replaceTimers(notices, details);
return;
}
try {
for (final pending in await _plugin.pendingNotificationRequests()) {
if (!ids.contains(pending.id)) {
await _plugin.cancel(id: pending.id);
}
}
final mode = await _exact()
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle;
for (final notice in notices) {
await _plugin.zonedSchedule(
id: notice.id,
title: notice.title,
body: notice.body,
scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC),
notificationDetails: details,
androidScheduleMode: mode,
);
}
} on Object catch (error) {
log('Reminders not scheduled: $error', name: 'Reminders');
}
}
/// Whether Android lets the app wake at the minute (its exact alarms,
/// which Android 14 leaves off until the person allows them in the
/// system's settings); elsewhere it always does.
Future<bool> _exact() async {
final android = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
return android == null ||
(await android.canScheduleExactNotifications() ?? false);
}
void _replaceTimers(
List<ReminderNotice> notices,
NotificationDetails details,
) {
for (final timer in _timers.values) {
timer.cancel();
}
_timers.clear();
final now = DateTime.now();
for (final notice in notices) {
_timers[notice.id] = Timer(notice.at.difference(now), () {
_timers.remove(notice.id);
unawaited(
_plugin.show(
id: notice.id,
title: notice.title,
body: notice.body,
notificationDetails: details,
),
);
});
}
}
@override
Future<bool> askPermission() async {
try {
return switch (defaultTargetPlatform) {
TargetPlatform.android =>
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false,
TargetPlatform.iOS =>
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
TargetPlatform.macOS =>
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
_ => true,
};
} on Object catch (error) {
log('Notifications not allowed: $error', name: 'Reminders');
return false;
}
}
}

View file

@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A reminder the device is to show (ADR 0033, D5): of the task [taskId]
/// titled [title], due at [time] (minutes after midnight, its wall clock),
/// [minutesBefore] it, at [at] (local time); [id] the same on every plan, so
/// that the system's notification is replaced, not doubled.
final class const PlannedReminder({
required final int id,
required final String taskId,
required final DateTime at,
required final int minutesBefore,
required final String title,
required final int time,
}) with Equatable {
@override
List<Object?> get props => [id, taskId, at, minutesBefore, title, time];
}
/// The reminders to schedule [now] (D5): every reminder of each open task
/// that has a time on its date, the nearest first, those still ahead within
/// [horizon], no more than [limit] (iOS keeps 64 pending; a recurring
/// task's later times are planned once it moves on). A task's time is its
/// wall clock, so the reminder is at that time wherever the device is now.
List<PlannedReminder> planReminders(
List<StoredTask> tasks, {
required DateTime now,
Duration horizon = const Duration(days: 30),
int limit = 48,
}) {
final end = now.add(horizon);
final plan = [
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(
completed: false,
date: TaskDate(:final day, time: final time?),
:final reminders,
:final title,
))
for (final before in reminders.toSet())
if (DateTime(
day.year,
day.month,
day.day,
0,
time - before,
)
case final at when at.isAfter(now) && !at.isAfter(end))
PlannedReminder(
id: reminderId(id, before),
taskId: id,
at: at,
minutesBefore: before,
title: title,
time: time,
),
]..sort((a, b) => a.at.compareTo(b.at));
return plan.take(limit).toList();
}
/// The system's notification ID of the task [taskId]'s reminder [before]
/// minutes ahead: 31 bits of an FNV-1a hash, the same in every run (Dart's
/// `hashCode` of a string isn't).
int reminderId(String taskId, int before) {
var hash = 0x811c9dc5;
for (final unit in '$taskId/$before'.codeUnits) {
hash = ((hash ^ unit) * 0x01000193) & 0xffffffff;
}
return hash & 0x7fffffff;
}

View file

@ -0,0 +1,72 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The reminders offered, in minutes before a task's time.
const reminderChoices = [0, 5, 10, 15, 30, 60, 120, 1440];
/// [before] minutes ahead of a task's time, in words: "At its time", "30
/// minutes before", "1 day before".
String reminderText(AppLocalizations l10n, int before) => switch (before) {
0 => l10n.reminderAtTime,
_ when before % 1440 == 0 => l10n.reminderDaysBefore(before ~/ 1440),
_ when before % 60 == 0 => l10n.reminderHoursBefore(before ~/ 60),
_ => l10n.reminderMinutesBefore(before),
};
/// Asks which reminders a task with a time has (ADR 0033, D5, D8), from
/// [current]; null where the person changed their mind.
Future<List<int>?> pickReminders(BuildContext context, List<int> current) =>
showAdaptiveSheet<List<int>>(
context,
builder: (context) => _ReminderSheet(current: current),
);
class const _ReminderSheet({required final List<int> current})
extends StatefulWidget {
@override
State<_ReminderSheet> createState() => _ReminderSheetState();
}
class _ReminderSheetState() extends State<_ReminderSheet> {
late final Set<int> _chosen = {...widget.current};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final offered = {...reminderChoices, ...widget.current}.toList()..sort();
return SheetScaffold(
title: l10n.remindersTitle,
footer: Padding(
padding: const EdgeInsets.all(16),
child: Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => Navigator.of(context).pop([..._chosen]..sort()),
child: Text(l10n.save),
),
),
),
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final before in offered)
CheckboxListTile(
title: Text(reminderText(l10n, before)),
value: _chosen.contains(before),
onChanged: (on) => setState(
() => on! ? _chosen.add(before) : _chosen.remove(before),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
l10n.remindersOnThisDevice,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
}

View file

@ -0,0 +1,90 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/reminder_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.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:material_ui/material_ui.dart';
/// Keeps the device's reminders in step with the person's tasks (ADR 0033,
/// D5): planned again whenever the tasks change, here or through sync,
/// when the app comes back to the front, and every few hours as the
/// planned weeks move on; handed to the system only where the plan changed.
class const ReminderScheduler({required final Widget child, super.key})
extends StatefulWidget {
@override
State<ReminderScheduler> createState() => _ReminderSchedulerState();
}
class _ReminderSchedulerState() extends State<ReminderScheduler> {
List<PlannedReminder>? _planned;
Locale? _locale;
Timer? _timer;
AppLifecycleListener? _lifecycle;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(hours: 6), (_) => _plan());
_lifecycle = AppLifecycleListener(onResume: _plan);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Words in another language: the same plan, said anew.
final locale = Localizations.localeOf(context);
if (locale != _locale) {
_locale = locale;
_planned = null;
_plan();
}
}
void _plan() {
final state = context.read<TodoCubit>().state;
if (!state.loaded) {
return;
}
final plan = planReminders([
for (final task in state.tasks) StoredTask(task.id, task.content),
], now: clock.now());
if (listEquals(plan, _planned)) {
return;
}
_planned = plan;
final l10n = AppLocalizations.of(context);
unawaited(
PersonScope.of<TodoPerson>(context).reminders.replace([
for (final reminder in plan)
(
id: reminder.id,
at: reminder.at,
title: reminder.title,
body: l10n.reminderDue(timeText(context, reminder.time)),
),
], channel: l10n.remindersChannel),
);
}
@override
void dispose() {
_timer?.cancel();
_lifecycle?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => BlocListener<TodoCubit, TodoState>(
listenWhen: (before, after) =>
before.loaded != after.loaded || !identical(before.tasks, after.tasks),
listener: (context, state) => _plan(),
child: widget.child,
);
}

View file

@ -29,6 +29,7 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
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.reminders.isNotEmpty) 'reminders': content.reminders,
if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty)
@ -81,6 +82,13 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
final String at => DateTime.parse(at).toLocal(),
_ => null,
},
reminders: [
for (final before in json['reminders'] as List<Object?>? ?? const [])
switch (before) {
final int minutes when minutes >= 0 => minutes,
_ => throw const FormatException('reminders'),
},
],
completions: [
for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(),

View file

@ -139,6 +139,10 @@ final class const TaskContent({
/// Its labels' IDs.
final List<String> labelIds = const [],
/// Its reminders, each so many minutes before the time on its [date]
/// (D5); none for a date without a time.
final List<int> reminders = const [],
/// When it was done; none: not yet.
final DateTime? completedAt,
@ -160,6 +164,7 @@ final class const TaskContent({
Day? Function()? deadline,
Priority? priority,
List<String>? labelIds,
List<int>? reminders,
DateTime? Function()? completedAt,
List<DateTime>? completions,
}) => TaskContent(
@ -173,6 +178,7 @@ final class const TaskContent({
deadline: deadline == null ? this.deadline : deadline(),
priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds,
reminders: reminders ?? this.reminders,
completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions,
);
@ -189,6 +195,7 @@ final class const TaskContent({
deadline,
priority,
labelIds,
reminders,
completedAt,
completions,
];

View file

@ -0,0 +1,117 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// The tasks of [tasks] each word of [query] is found in (ADR 0033, D2):
/// in its title, its description or one of its labels' names
/// ([labelNames], by ID), whatever the case and the accents; open ones
/// first, by their date, the nearest first, the undated after them; done
/// ones last. Nothing for a query of no words. On the device alone: the
/// instance reads no task (D5).
List<OpenTask> searchTasks(
List<OpenTask> tasks,
String query, {
Map<String, String> labelNames = const {},
}) {
final words = folded(query).split(RegExp(r'\s+'))
..removeWhere((word) => word.isEmpty);
if (words.isEmpty) {
return const [];
}
final found = [
for (final task in tasks)
if (folded(
[
task.content.title,
task.content.description,
for (final id in task.content.labelIds) ?labelNames[id],
].join('\n'),
)
case final text when words.every(text.contains))
task,
];
int rank(OpenTask task) => task.content.completed
? 2
: task.content.date == null
? 1
: 0;
return found..sort((a, b) {
final byRank = rank(a).compareTo(rank(b));
if (byRank != 0) {
return byRank;
}
final (ad, bd) = (a.content.date?.day, b.content.date?.day);
if (ad != null && bd != null && ad != bd) {
return ad.compareTo(bd);
}
return byTime(a, b);
});
}
/// [text] in lower case without its accents, so that "Café" is found by
/// "cafe" and "Straße" by "strasse".
String folded(String text) {
final lower = text.toLowerCase();
final out = StringBuffer();
for (final rune in lower.runes) {
final char = String.fromCharCode(rune);
out.write(_plain[char] ?? char);
}
return out.toString();
}
/// The Latin letters with diacritics Todo's languages and their neighbours
/// write, and what they are searched as.
const _plain = {
'à': 'a',
'á': 'a',
'â': 'a',
'ã': 'a',
'ä': 'a',
'å': 'a',
'ā': 'a',
'ą': 'a',
'æ': 'ae',
'ç': 'c',
'ć': 'c',
'č': 'c',
'ď': 'd',
'è': 'e',
'é': 'e',
'ê': 'e',
'ë': 'e',
'ē': 'e',
'ę': 'e',
'ě': 'e',
'ì': 'i',
'í': 'i',
'î': 'i',
'ï': 'i',
'ī': 'i',
'ł': 'l',
'ñ': 'n',
'ń': 'n',
'ň': 'n',
'ò': 'o',
'ó': 'o',
'ô': 'o',
'õ': 'o',
'ö': 'o',
'ø': 'o',
'ō': 'o',
'œ': 'oe',
'ř': 'r',
'ś': 's',
'š': 's',
'ß': 'ss',
'ť': 't',
'ù': 'u',
'ú': 'u',
'û': 'u',
'ü': 'u',
'ū': 'u',
'ů': 'u',
'ý': 'y',
'ÿ': 'y',
'ź': 'z',
'ż': 'z',
'ž': 'z',
};

View file

@ -0,0 +1,155 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_search.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens search over the current tab (ADR 0033, D2), in its own stack, so
/// that back returns to it and a task found opens beside the list where
/// the window is wide.
Future<void> openSearch(BuildContext context) => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (context) => const SearchScreen()));
/// Search (ADR 0033, D2): a field in the app bar, and as it is typed in
/// the projects whose name holds it, then the tasks every word is found in
/// ([searchTasks]), open ones first, each with its project; all on the
/// device, as the instance reads no task (D5).
class const SearchScreen({super.key}) extends StatefulWidget {
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState() extends State<SearchScreen> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final query = _query.text;
final words = folded(query).trim();
final tasks = searchTasks(
state.tasks,
query,
labelNames: {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
},
);
final projects = words.isEmpty
? const <({String id, String name})>[]
: [
for (final project in state.projects)
if (project.content case final content?)
if ((
id: project.id,
name: project.inbox ? l10n.shellTabInbox : content.name,
)
case final found when folded(found.name).contains(words))
found,
];
final muted = theme.colorScheme.onSurfaceVariant;
Widget heading(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.titleSmall?.copyWith(color: muted),
),
);
Widget message(String text) => Padding(
padding: const EdgeInsets.all(24),
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(color: muted),
),
);
return TopGradient(
background: theme.colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: TextField(
controller: _query,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.searchHint,
border: InputBorder.none,
),
onChanged: (_) => setState(() {}),
),
actions: [
if (query.isNotEmpty)
IconButton(
tooltip: l10n.clear,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(_query.clear),
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: EdgeInsets.only(
bottom: 16 + FloatingNavigationBar.coverOf(context),
),
children: [
if (words.isEmpty)
message(l10n.searchEmpty)
else if (projects.isEmpty && tasks.isEmpty)
message(l10n.searchNone(query.trim()))
else ...[
if (projects.isNotEmpty) ...[
heading(l10n.searchProjects),
for (final project in projects)
ListTile(
leading: Icon(
project.id == state.inboxId
? LucideIcons.inbox
: LucideIcons.hash,
),
title: Text(project.name),
onTap: () => context.go(
project.id == state.inboxId
? TodoPaths.inbox
: TodoPaths.project(project.id),
),
),
],
if (tasks.isNotEmpty) ...[
heading(l10n.searchTasks),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
],
],
),
),
);
}
}
/// The search button every tab's app bar carries (D2).
class const SearchButton({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IconButton(
tooltip: AppLocalizations.of(context).searchTitle,
icon: const Icon(LucideIcons.search),
onPressed: () => openSearch(context),
);
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
@ -23,10 +24,84 @@ import 'package:material_ui/material_ui.dart';
/// Opens the task [id]'s sheet (ADR 0033, D8): its title and description to
/// edit, a row for each of its date, deadline, priority, project and labels,
/// its sub-tasks with one more to add, and deleting it, which asks first.
Future<void> openTaskSheet(BuildContext context, String id) =>
showAdaptiveSheet<void>(context, builder: (context) => _TaskSheet(id: id));
/// From expanded windows on, where a [TaskPane] is above, it opens in the
/// pane beside the list instead (D2).
Future<void> openTaskSheet(BuildContext context, String id) async {
if (context.windowSize.isAtLeastExpanded) {
if (TaskPane.maybeOf(context) case final pane?) {
pane.value = id;
return;
}
}
await showAdaptiveSheet<void>(
context,
builder: (context) => _TaskSheet(id: id),
);
}
class const _TaskSheet({required final String id}) extends StatefulWidget {
/// The task open beside the list from expanded windows on (ADR 0033, D2,
/// Material's list–detail layout): [TaskPane.maybeOf] its ID, none for no task;
/// [child] the list, the task's details beside it on the right.
class const TaskPane({required final Widget child, super.key})
extends StatefulWidget {
/// How wide the pane is.
static const width = 420.0;
/// The open task's ID above [context], where a pane is.
static ValueNotifier<String?>? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_TaskPaneScope>()?.notifier;
@override
State<TaskPane> createState() => _TaskPaneState();
}
class _TaskPaneState() extends State<TaskPane> {
final _open = ValueNotifier<String?>(null);
@override
void dispose() {
_open.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => _TaskPaneScope(
notifier: _open,
child: ValueListenableBuilder(
valueListenable: _open,
builder: (context, id, list) => switch (id) {
final id? when context.windowSize.isAtLeastExpanded => Row(
children: [
Expanded(child: list!),
const VerticalDivider(width: 1),
SizedBox(
width: TaskPane.width,
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
// Another task opened is another state: what was typed
// into the last is kept as it goes.
child: _TaskSheet(key: ValueKey(id), id: id, inPane: true),
),
),
],
),
_ => list!,
},
child: widget.child,
),
);
}
class const _TaskPaneScope({
required ValueNotifier<String?> super.notifier,
required super.child,
}) extends InheritedNotifier<ValueNotifier<String?>>;
class const _TaskSheet({
required final String id,
final bool inPane = false,
super.key,
}) extends StatefulWidget {
@override
State<_TaskSheet> createState() => _TaskSheetState();
}
@ -119,6 +194,65 @@ class _TaskSheetState() extends State<_TaskSheet> {
}
}
/// What closes it: the pane's close, or the sheet's route.
VoidCallback _closer(BuildContext context) {
if (widget.inPane) {
final pane = TaskPane.maybeOf(context);
return () => pane?.value = null;
}
final sheet = Navigator.of(context);
return sheet.pop;
}
/// Its frame: the sheet's, or the pane's, its title, action and close
/// button on top and the rest scrolling below.
Widget _frame(
BuildContext context, {
required String title,
required Widget body,
Widget? action,
}) {
if (!widget.inPane) {
return SheetScaffold(
title: title,
hasSnackBars: true,
action: action,
body: body,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 8, 0),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
?action,
IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: const Icon(LucideIcons.x),
onPressed: _closer(context),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: body,
),
),
],
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -127,10 +261,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
final task = state.taskOf(widget.id);
if (task == null) {
// Deleted here or on another device.
return SheetScaffold(
title: l10n.taskTitle,
body: Text(l10n.taskGone),
);
return _frame(context, title: l10n.taskTitle, body: Text(l10n.taskGone));
}
final content = task.content;
final today = Day.of(clock.now());
@ -145,17 +276,17 @@ class _TaskSheetState() extends State<_TaskSheet> {
icon: const Icon(LucideIcons.x),
onPressed: onPressed,
);
return SheetScaffold(
return _frame(
context,
title: switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
},
hasSnackBars: true,
action: IconButton(
tooltip: l10n.taskDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () async {
final sheet = Navigator.of(context);
final close = _closer(context);
if (await confirmDestructiveAction(
context,
title: l10n.taskDeleteTitle,
@ -165,7 +296,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
confirmLabel: l10n.taskDelete,
)) {
await _tasks.delete(widget.id);
sheet.pop();
close();
}
},
),
@ -249,6 +380,37 @@ class _TaskSheetState() extends State<_TaskSheet> {
}
},
),
if (content.date?.time != null)
ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(
content.reminders.isEmpty
? l10n.remindersNone
: [
for (final before in content.reminders)
reminderText(l10n, before),
].join(', '),
),
onTap: () async {
final chosen = await pickReminders(context, content.reminders);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
if (content.reminders.isEmpty && chosen.isNotEmpty) {
final reminders = PersonScope.of<TodoPerson>(
context,
).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change((c) => c.copyWith(reminders: chosen));
},
),
ListTile(
leading: Icon(
LucideIcons.flag,

View file

@ -1,3 +1,4 @@
import 'package:halkyon_todo_flutter/src/feature/task/widget/search_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -17,6 +18,9 @@ class const TabScreen({
/// The screen's button, above the navigation bar.
final Widget? floatingActionButton,
/// Whether its app bar carries search (D2), as every tab's does.
final bool search = true,
super.key,
}) extends StatelessWidget {
@override
@ -28,7 +32,11 @@ class const TabScreen({
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(title),
actions: [...actions, const SizedBox(width: 4)],
actions: [
if (search) const SearchButton(),
...actions,
const SizedBox(width: 4),
],
),
body: body,
// Clear of the navigation bar floating over the foot on a phone.

View file

@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -56,7 +57,8 @@ class const TodoShell({
selectedIcon: null,
),
],
child: shell,
// From expanded windows on, a task opens beside the list (D2).
child: TaskPane(child: shell),
),
);
}

View file

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
@ -9,63 +12,175 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// How many days Upcoming lists, from today on.
/// How many days Upcoming lists at least, from today on.
const upcomingDays = 28;
/// Upcoming (ADR 0033, D2, D8): what is overdue, then every day of the
/// next four weeks with its tasks, a day without any kept but muted, and on
/// each a button adding a task for that day.
class const UpcomingTab({super.key}) extends StatelessWidget {
/// Upcoming (ADR 0033, D2, D8): the week strip on top ([WeekStrip]), and
/// below it what is overdue, then every day from today on with its tasks, a
/// day without any kept but muted, and on each a button adding a task for
/// that day. A day picked in the strip scrolls the agenda to it (which
/// reaches as far as the day picked, four weeks at least); scrolling the
/// agenda moves the strip along.
class const UpcomingTab({super.key}) extends StatefulWidget {
@override
State<UpcomingTab> createState() => _UpcomingTabState();
}
class _UpcomingTabState() extends State<UpcomingTab> {
final _scroll = ScrollController();
final _headings = <Day, GlobalKey>{};
Day? _selected;
/// Whether the agenda moves because a day was picked: then the strip
/// doesn't follow the scrolling.
bool _jumping = false;
GlobalKey _keyOf(Day day) => _headings.putIfAbsent(day, GlobalKey.new);
Future<void> _select(Day day) async {
setState(() => _selected = day);
_jumping = true;
// Once the agenda reaches the day.
await WidgetsBinding.instance.endOfFrame;
final heading = _headings[day]?.currentContext;
if (heading != null && heading.mounted) {
await Scrollable.ensureVisible(
heading,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
_jumping = false;
}
/// The day whose heading is the last at or above the agenda's top.
void _follow(Day today) {
if (_jumping) {
return;
}
final viewport = _scroll.position.context.storageContext.findRenderObject();
if (viewport is! RenderBox) {
return;
}
final top = viewport.localToGlobal(Offset.zero).dy;
Day? atTop;
final days = _headings.keys.where((day) => !(day < today)).toList()..sort();
for (final day in days) {
final box = _headings[day]?.currentContext?.findRenderObject();
if (box is! RenderBox || !box.attached) {
continue;
}
if (box.localToGlobal(Offset.zero).dy <= top + 8) {
atTop = day;
} else {
break;
}
}
final day = atTop ?? today;
if (day != (_selected ?? today)) {
setState(() => _selected = day);
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final today = Day.of(clock.now());
final (:overdue, :days) = upcomingOf(
state.tasks,
today,
days: upcomingDays,
final selected = switch (_selected) {
final day? when !(day < today) => day,
_ => today,
};
final firstWeekday = MaterialLocalizations.of(context).firstDayOfWeekIndex;
final reach = max(
upcomingDays,
today.daysUntil(WeekStrip.weekOf(selected, firstWeekday).plus(6)) + 1,
);
final (:overdue, :days) = upcomingOf(state.tasks, today, days: reach);
final busy = {
for (final task in state.tasks)
if (task.content case TaskContent(
completed: false,
date: TaskDate(:final day),
))
day,
};
return TabScreen(
title: l10n.shellTabUpcoming,
floatingActionButton: AddTaskButton(
onPressed: () => showQuickAdd(context, day: today),
onPressed: () => showQuickAdd(context, day: selected),
),
body: !state.loaded
? const Center(child: AppLoadingIndicator())
: ListView(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
: Column(
children: [
if (overdue.isNotEmpty) ...[
DayHeading(
title: l10n.overdue,
action: TextButton(
onPressed: () => reschedule(context, overdue),
child: Text(l10n.reschedule),
WeekStrip(
selected: selected,
today: today,
busy: busy,
onSelect: (day) => unawaited(_select(day)),
),
const Divider(height: 1),
Expanded(
child: NotificationListener<ScrollUpdateNotification>(
onNotification: (_) {
_follow(today);
return false;
},
// Every day built, so that a day picked can be scrolled
// to: a few weeks of tasks, not thousands.
child: SingleChildScrollView(
controller: _scroll,
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (overdue.isNotEmpty) ...[
DayHeading(
title: l10n.overdue,
action: TextButton(
onPressed: () => reschedule(context, overdue),
child: Text(l10n.reschedule),
),
),
for (final task in overdue)
TaskTile(task: task, showProject: true),
],
for (final (day, tasks) in days) ...[
DayHeading(
key: _keyOf(day),
title: dayHeading(context, day, today),
quiet: tasks.isEmpty,
action: IconButton(
tooltip: l10n.taskAddOn(
dayHeading(context, day, today),
),
icon: const Icon(LucideIcons.plus),
onPressed: () =>
showQuickAdd(context, day: day),
),
),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
],
),
),
),
for (final task in overdue)
TaskTile(task: task, showProject: true),
],
for (final (day, tasks) in days) ...[
DayHeading(
title: dayHeading(context, day, today),
quiet: tasks.isEmpty,
action: IconButton(
tooltip: l10n.taskAddOn(dayHeading(context, day, today)),
icon: const Icon(LucideIcons.plus),
onPressed: () => showQuickAdd(context, day: day),
),
),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
),
],
),
);

Some files were not shown because too many files have changed in this diff Show more