fix(todo): the permission to notify asked wherever a reminder is planned, and Settings saying while it is off (ADR 0033, D31)

On a fresh install Todo's reminders never showed: the permission to notify
(Android 13's POST_NOTIFICATIONS, iOS' and macOS' alert) was asked only
where a task's sheet chose its first reminder, so reminders typed in quick
add, added by the automatic reminder or brought by sync were scheduled and
dropped by the system without a word.

Now NotificationAccess, the device's, reads whether notifications are
allowed whenever the plan holds a reminder and asks where they aren't,
once an app start, so that a refusal isn't met with the dialog again and
again; the task sheet asks through it. While reminders are planned and
notifications are off, Settings' Reminders say so, with Turn on: asked
again, and where that doesn't allow it, the system's notification settings
of the app opened (flutter_local_notifications' own call, nothing added).
Read again whenever the app comes back.

Whats-New: Todo asks to notify as soon as there is a reminder, and Settings › Reminders shows when notifications are off, with Turn on.
Whats-New-De: Todo fragt nach Benachrichtigungen, sobald es eine Erinnerung gibt, und Einstellungen › Erinnerungen zeigt, wenn sie aus sind, mit Einschalten.

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-04 22:35:06 +01:00
commit 82f1bf2739
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 458 additions and 33 deletions

File diff suppressed because one or more lines are too long

View file

@ -254,6 +254,9 @@
"snoozeTitle": "Schlummern für", "snoozeTitle": "Schlummern für",
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}", "snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
"reminderSnooze": "{count} Min. schlummern", "reminderSnooze": "{count} Min. schlummern",
"notificationsOffTitle": "Benachrichtigungen sind aus",
"notificationsOffHint": "Erinnerungen erscheinen erst, wenn Halkyon Todo dich benachrichtigen darf.",
"notificationsTurnOn": "Einschalten",
"monthMore": "{count} weitere", "monthMore": "{count} weitere",
"dayHeadingPattern": "EEEE, d. MMM", "dayHeadingPattern": "EEEE, d. MMM",
"overdueHide": "Überfälliges ausblenden", "overdueHide": "Überfälliges ausblenden",

View file

@ -812,6 +812,18 @@
} }
} }
}, },
"notificationsOffTitle": "Notifications are off",
"@notificationsOffTitle": {
"description": "Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31)."
},
"notificationsOffHint": "Reminders can't show until Halkyon Todo may notify you.",
"@notificationsOffHint": {
"description": "Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31)."
},
"notificationsTurnOn": "Turn on",
"@notificationsTurnOn": {
"description": "Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31)."
},
"monthMore": "{count} more", "monthMore": "{count} more",
"@monthMore": { "@monthMore": {
"description": "A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit.", "description": "A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit.",

View file

@ -1624,6 +1624,24 @@ abstract class AppLocalizations {
/// **'Snooze {count} min'** /// **'Snooze {count} min'**
String reminderSnooze(int count); String reminderSnooze(int count);
/// Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31).
///
/// In en, this message translates to:
/// **'Notifications are off'**
String get notificationsOffTitle;
/// Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31).
///
/// In en, this message translates to:
/// **'Reminders can\'t show until Halkyon Todo may notify you.'**
String get notificationsOffHint;
/// Settings' Reminders while notifications are off and reminders are planned (ADR 0033, D31).
///
/// In en, this message translates to:
/// **'Turn on'**
String get notificationsTurnOn;
/// A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit. /// A project's Calendar as a month on wide windows (ADR 0033, D17): a day's tasks that don't fit.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1006,6 +1006,16 @@ class AppLocalizationsDe extends AppLocalizations {
return '$count Min. schlummern'; return '$count Min. schlummern';
} }
@override
String get notificationsOffTitle => 'Benachrichtigungen sind aus';
@override
String get notificationsOffHint =>
'Erinnerungen erscheinen erst, wenn Halkyon Todo dich benachrichtigen darf.';
@override
String get notificationsTurnOn => 'Einschalten';
@override @override
String monthMore(int count) { String monthMore(int count) {
return '$count weitere'; return '$count weitere';

View file

@ -1006,6 +1006,16 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Snooze $count min'; return 'Snooze $count min';
} }
@override
String get notificationsOffTitle => 'Notifications are off';
@override
String get notificationsOffHint =>
'Reminders can\'t show until Halkyon Todo may notify you.';
@override
String get notificationsTurnOn => 'Turn on';
@override @override
String monthMore(int count) { String monthMore(int count) {
return '$count more'; return '$count more';

View file

@ -15,6 +15,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.d
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart'; import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/notification_access.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_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/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/section_repository.dart';
@ -61,8 +62,16 @@ class TodoPerson(
/// The device's notifications, where their tasks' reminders go (ADR /// The device's notifications, where their tasks' reminders go (ADR
/// 0033, D5); none in tests that don't say. /// 0033, D5); none in tests that don't say.
final ReminderGateway reminders = const NoReminders(), final ReminderGateway reminders = const NoReminders(),
/// Whether the device may show their reminders, asked once an app start
/// (D31): the device's, where it is given; else one of their own.
final NotificationAccess? _notifications,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) implements Disposable { }) implements Disposable {
/// Whether the device may show their reminders (ADR 0033, D31).
late final NotificationAccess notifications =
_notifications ?? NotificationAccess(reminders);
/// Deletes the project [id] with its sections and tasks, and its /// Deletes the project [id] with its sections and tasks, and its
/// sub-projects with theirs, as Todoist does (ADR 0033, D28); throws /// sub-projects with theirs, as Todoist does (ADR 0033, D28); throws
/// [InboxKept] for the Inbox, which keeps everything. /// [InboxKept] for the Inbox, which keeps everything.
@ -274,6 +283,7 @@ class TodoDependencies({
await folders.openOnly(person.authUserId), await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId), accountKeys: () => accountKeys.read(person.authUserId),
reminders: device.reminders, reminders: device.reminders,
notifications: device.notifications,
onClosed: closed.complete, onClosed: closed.complete,
); );
} on Object { } on Object {
@ -311,6 +321,7 @@ Future<TodoPerson> _openPersonData(
Directory folder, { Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys, required Future<UnlockedAccountKeys?> Function() accountKeys,
required ReminderGateway reminders, required ReminderGateway reminders,
required NotificationAccess notifications,
required void Function() onClosed, required void Function() onClosed,
}) async { }) async {
final db = await client.createSyncSession( final db = await client.createSyncSession(
@ -368,6 +379,7 @@ Future<TodoPerson> _openPersonData(
labels: labels, labels: labels,
settings: settings, settings: settings,
reminders: reminders, reminders: reminders,
notifications: notifications,
onDispose: () async { onDispose: () async {
try { try {
await projects.dispose(); await projects.dispose();
@ -405,6 +417,10 @@ class TodoDevice({
/// The system's notifications, where reminders go (ADR 0033, D5). /// The system's notifications, where reminders go (ADR 0033, D5).
final ReminderGateway reminders = const NoReminders(), final ReminderGateway reminders = const NoReminders(),
}) { }) {
/// Whether the device may show reminders, asked once an app start,
/// whoever is signed in (D31).
late final NotificationAccess notifications = NotificationAccess(reminders);
/// Makes them, [builtIn] being the instance the build names. /// Makes them, [builtIn] being the instance the build names.
static Future<TodoDevice> create({required Instance builtIn}) async { static Future<TodoDevice> create({required Instance builtIn}) async {
final package = await PackageInfo.fromPlatform(); final package = await PackageInfo.fromPlatform();

View file

@ -0,0 +1,68 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
/// Whether the device may show the reminders it plans (ADR 0033, D31): asked
/// for wherever a reminder is planned, typed in quick add, added by the
/// automatic reminder, chosen in a task's sheet or brought by sync, once an
/// app start, so that a person who refused isn't asked again and again
/// (Android itself stops asking after two refusals, iOS after one); and
/// while reminders are planned and notifications are off, Settings says so
/// ([off]) and offers to turn them on ([turnOn]).
///
/// One for the device, made with its [ReminderGateway]: the app start, not
/// the person, counts.
class NotificationAccess(final ReminderGateway _gateway)
extends ChangeNotifier {
bool _asked = false;
bool _planned = false;
bool? _allowed;
/// Whether reminders are planned and the device may not show them.
bool get off => _planned && _allowed == false;
/// The plan holds reminders ([any]) or none: where it holds some, whether
/// the device may notify is read again (the person may have changed it in
/// the system's settings meanwhile), and where it may not, the person is
/// asked, unless they were since the app started.
Future<void> planned({required bool any}) async {
if (any != _planned) {
_planned = any;
notifyListeners();
}
if (!any) {
return;
}
final allowed = await _gateway.notificationsAllowed();
if (allowed || _asked) {
_set(allowed);
return;
}
_asked = true;
_set(await _gateway.askPermission());
}
/// Asks the person to let the app notify, where the system still asks;
/// whether it may.
Future<bool> ask() async {
_asked = true;
final allowed = await _gateway.askPermission();
_set(allowed);
return allowed;
}
/// Settings' Turn on: asks again, and where that doesn't allow it (refused
/// again, or the system asks no more), opens the system's notification
/// settings of the app, where the person can turn them on.
Future<void> turnOn() async {
if (!await ask()) {
await _gateway.openSettings();
}
}
void _set(bool allowed) {
if (allowed != _allowed) {
_allowed = allowed;
notifyListeners();
}
}
}

View file

@ -16,9 +16,17 @@ abstract interface class ReminderGateway() {
({int minutes, String label})? snooze, ({int minutes, String label})? snooze,
}); });
/// Whether the app may show notifications now (ADR 0033, D31): on
/// Android 13 on, iOS and macOS not until the person allows it.
Future<bool> notificationsAllowed();
/// Asks the person to let the app show notifications, where the system /// Asks the person to let the app show notifications, where the system
/// asks; whether it may. /// asks; whether it may.
Future<bool> askPermission(); Future<bool> askPermission();
/// Opens the system's notification settings of the app, where the person
/// turns them on once the system no longer asks (D31).
Future<void> openSettings();
} }
/// No reminders at all: a device or test without notifications. /// No reminders at all: a device or test without notifications.
@ -30,6 +38,13 @@ class const NoReminders() implements ReminderGateway {
({int minutes, String label})? snooze, ({int minutes, String label})? snooze,
}) async {} }) async {}
/// Nothing to turn on: nothing is shown either way.
@override
Future<bool> notificationsAllowed() async => true;
@override @override
Future<bool> askPermission() async => false; Future<bool> askPermission() async => false;
@override
Future<void> openSettings() async {}
} }

View file

@ -201,7 +201,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
onDidReceiveBackgroundNotificationResponse: snoozeInBackground, onDidReceiveBackgroundNotificationResponse: snoozeInBackground,
settings: InitializationSettings( settings: InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'), android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Asked for when the person first adds a reminder, not at start. // Asked for once a reminder is planned (D31), not at start.
iOS: DarwinInitializationSettings( iOS: DarwinInitializationSettings(
requestAlertPermission: false, requestAlertPermission: false,
requestBadgePermission: false, requestBadgePermission: false,
@ -291,14 +291,14 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
} }
} }
/// Whether Android lets the app wake at the minute (its exact alarms, /// Whether Android lets the app wake at the minute (ADR 0033, D31): on
/// which Android 14 leaves off until the person allows them in the /// Android 13 on through `USE_EXACT_ALARM`, granted with the install to
/// system's settings); elsewhere it always does. /// an app that reminds of what a person planned, on 12 and 12L through
/// `SCHEDULE_EXACT_ALARM`, granted unless the person took it back (the
/// manifest declares it up to API 32); `AlarmManager.canScheduleExactAlarms`
/// is true with either. Elsewhere it always does.
Future<bool> _exact() async { Future<bool> _exact() async {
final android = _plugin final android = _android;
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
return android == null || return android == null ||
(await android.canScheduleExactNotifications() ?? false); (await android.canScheduleExactNotifications() ?? false);
} }
@ -329,31 +329,65 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
} }
} }
AndroidFlutterLocalNotificationsPlugin? get _android => _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
IOSFlutterLocalNotificationsPlugin? get _iOS => _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
MacOSFlutterLocalNotificationsPlugin? get _macOS => _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>();
@override
Future<bool> notificationsAllowed() async {
try {
return switch (defaultTargetPlatform) {
TargetPlatform.android =>
await _android?.areNotificationsEnabled() ?? false,
TargetPlatform.iOS =>
(await _iOS?.checkPermissions())?.isEnabled ?? false,
TargetPlatform.macOS =>
(await _macOS?.checkPermissions())?.isEnabled ?? false,
// Linux and Windows ask nothing of an app before it notifies.
_ => true,
};
} on Object catch (error) {
log('Notifications not known: $error', name: 'Reminders');
// Not known: nothing asked, nothing said.
return true;
}
}
@override
Future<void> openSettings() async {
try {
await switch (defaultTargetPlatform) {
TargetPlatform.android => _android?.openAppNotificationSettings(),
TargetPlatform.iOS => _iOS?.openAppNotificationSettings(),
TargetPlatform.macOS => _macOS?.openAppNotificationSettings(),
_ => null,
};
} on Object catch (error) {
log('Notification settings not opened: $error', name: 'Reminders');
}
}
@override @override
Future<bool> askPermission() async { Future<bool> askPermission() async {
try { try {
return switch (defaultTargetPlatform) { return switch (defaultTargetPlatform) {
TargetPlatform.android => TargetPlatform.android =>
await _plugin await _android?.requestNotificationsPermission() ?? false,
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false,
TargetPlatform.iOS => TargetPlatform.iOS =>
await _plugin await _iOS?.requestPermissions(alert: true, sound: true) ?? false,
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
TargetPlatform.macOS => TargetPlatform.macOS =>
await _plugin await _macOS?.requestPermissions(alert: true, sound: true) ?? false,
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
_ => true, _ => true,
}; };
} on Object catch (error) { } on Object catch (error) {

View file

@ -16,7 +16,9 @@ import 'package:material_ui/material_ui.dart';
/// D5): planned again whenever the tasks change, here or through sync, /// 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 /// 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, or how /// planned weeks move on; handed to the system only where the plan, or how
/// long a reminder is snoozed for (D4), changed. /// long a reminder is snoozed for (D4), changed. Where the plan holds a
/// reminder, the person is asked to let the app notify (D31,
/// `NotificationAccess`).
class const ReminderScheduler({required final Widget child, super.key}) class const ReminderScheduler({required final Widget child, super.key})
extends StatefulWidget { extends StatefulWidget {
@override @override
@ -57,6 +59,14 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
final plan = planReminders([ final plan = planReminders([
for (final task in state.tasks) StoredTask(task.id, task.content), for (final task in state.tasks) StoredTask(task.id, task.content),
], now: clock.now()); ], now: clock.now());
// Asked to notify wherever the reminders came from (D31): typed, added
// by the automatic reminder, chosen in a sheet or synced; and read
// again as the app comes back, from the system's settings perhaps.
unawaited(
PersonScope.of<TodoPerson>(
context,
).notifications.planned(any: plan.isNotEmpty),
);
final snooze = state.settings.reminders.snooze; final snooze = state.settings.reminders.snooze;
if (listEquals(plan, _planned) && snooze == _snooze) { if (listEquals(plan, _planned) && snooze == _snooze) {
return; return;

View file

@ -840,9 +840,10 @@ class _TaskSheetState() extends State<_TaskSheet> {
(timed && content.reminders.isNotEmpty) || (timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty; content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) { if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(context).reminders; // Through the device's ask (D31), so that the plan's doesn't follow.
final notifications = PersonScope.of<TodoPerson>(context).notifications;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) { if (!await notifications.ask()) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)), SnackBar(content: Text(l10n.remindersNotAllowed)),
); );

View file

@ -79,7 +79,9 @@ class const TaskSettingsCard({super.key}) extends StatelessWidget {
/// Settings' *Reminders* (D4): the automatic reminder a task given a time /// Settings' *Reminders* (D4): the automatic reminder a task given a time
/// gets where it has none of its own (none at first), and how long a /// gets where it has none of its own (none at first), and how long a
/// reminder is snoozed for from its notification. /// reminder is snoozed for from its notification; above them, while
/// reminders are planned and the device may not show them, that
/// notifications are off, with Turn on (D31).
class const ReminderSettingsCard({super.key}) extends StatelessWidget { class const ReminderSettingsCard({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -94,6 +96,7 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
return _Card( return _Card(
title: l10n.remindersTitle, title: l10n.remindersTitle,
children: [ children: [
const _NotificationsOff(),
ListTile( ListTile(
leading: const Icon(LucideIcons.alarmClockPlus), leading: const Icon(LucideIcons.alarmClockPlus),
title: ExplainedTitle( title: ExplainedTitle(
@ -146,6 +149,36 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
} }
} }
/// That notifications are off while reminders are planned (D31), with Turn
/// on: asked again, or where the system asks no more, its notification
/// settings of the app opened; nothing while they are on, or nothing is
/// planned.
class const _NotificationsOff() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final notifications = PersonScope.of<TodoPerson>(context).notifications;
return ListenableBuilder(
listenable: notifications,
builder: (context, _) {
if (!notifications.off) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(LucideIcons.bellOff, color: colors.error),
title: Text(l10n.notificationsOffTitle),
subtitle: Text(l10n.notificationsOffHint),
trailing: FilledButton.tonal(
onPressed: notifications.turnOn,
child: Text(l10n.notificationsTurnOn),
),
);
},
);
}
}
/// A group of Settings under its [title]. /// A group of Settings under its [title].
class const _Card({ class const _Card({
required final String title, required final String title,

View file

@ -3926,6 +3926,146 @@ void main() {
); );
}); });
}); });
group('Notifications asked for wherever a reminder is planned (D31)', () {
/// Whether Settings' Reminders say that notifications are off.
Finder off() => find.text('Notifications are off');
testWidgets('a reminder typed in quick add asks to notify, once an '
'app start, however many follow', (tester) async {
reminders.allowed = false;
await start(tester, usedBefore: true);
expect(reminders.asked, 0, reason: 'nothing to remind of yet');
await goTab(tester, 'Inbox');
await quickAdd(tester, 'Leave !30m');
expect(reminders.scheduled, hasLength(1));
expect(reminders.asked, 1);
await quickAdd(tester, 'Call back !1h');
expect(reminders.scheduled, hasLength(2));
expect(reminders.asked, 1, reason: 'refused once, not asked again');
});
testWidgets('the automatic reminder of a task given a time asks to '
'notify', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => SettingsRepository(settingsRows, keys).write(
const TodoSettings(reminders: ReminderSettings(automatic: 15)),
),
);
settingsRows.bringRemoteChanges();
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
await quickAdd(tester, 'Call tomorrow 9am');
expect(reminders.scheduled, hasLength(1));
expect(reminders.asked, 1);
expect(reminders.enabled, isTrue);
});
testWidgets('a reminder another device synced asks to notify; nothing '
'is asked where none is planned', (tester) async {
final tomorrow = Day.of(DateTime.now()).plus(1);
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Call the bank',
projectId: inbox(),
date: TaskDate(tomorrow, time: 14 * 60),
),
);
await goTab(tester, 'Inbox');
await quickAdd(tester, 'Buy milk');
expect(reminders.asked, 0, reason: 'nothing to remind of');
await openSettings(tester);
expect(off(), findsNothing, reason: 'no reminder needs them');
await tester.pageBack();
await tester.pumpAndSettle();
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(tomorrow, time: 9 * 60),
reminders: const [30],
),
);
expect(reminders.asked, 1);
});
testWidgets("refused, Settings' Reminders say notifications are off; "
"Turn on asks again, then opens the system's settings, and "
'notifications allowed there count once Todo is back', (
tester,
) async {
final tomorrow = Day.of(DateTime.now()).plus(1);
reminders.allowed = false;
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(tomorrow, time: 9 * 60),
reminders: const [30],
),
);
expect(reminders.asked, 1);
await openSettings(tester);
expect(off(), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Turn on'));
await tester.pumpAndSettle();
expect(reminders.asked, 2);
expect(
reminders.openedSettings,
1,
reason: 'refused again, or the system no longer asks',
);
expect(off(), findsOneWidget);
// Allowed in the system's settings, then back to Todo.
reminders.enabled = true;
tester.binding
..handleAppLifecycleStateChanged(AppLifecycleState.inactive)
..handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpAndSettle();
expect(off(), findsNothing);
expect(reminders.asked, 2);
});
testWidgets('Turn on allowed asks no more and opens nothing', (
tester,
) async {
final tomorrow = Day.of(DateTime.now()).plus(1);
reminders.allowed = false;
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(tomorrow, time: 9 * 60),
reminders: const [30],
),
);
await openSettings(tester);
reminders.allowed = true;
await tester.tap(find.widgetWithText(FilledButton, 'Turn on'));
await tester.pumpAndSettle();
expect(reminders.asked, 2);
expect(reminders.openedSettings, 0);
expect(off(), findsNothing);
});
});
}); });
group("Upcoming's strip (ADR 0033, D8, D14)", () { group("Upcoming's strip (ADR 0033, D8, D14)", () {

View file

@ -15,9 +15,17 @@ class MemoryReminderGateway() implements ReminderGateway {
/// What the person answers when asked. /// What the person answers when asked.
bool allowed = true; bool allowed = true;
/// Whether the app may notify now: not on a fresh install until the
/// person is asked and allows it (Android 13 on, iOS, macOS), or allows
/// it in the system's settings meanwhile.
bool enabled = false;
/// How often they were asked. /// How often they were asked.
int asked = 0; int asked = 0;
/// How often the system's notification settings of the app were opened.
int openedSettings = 0;
@override @override
Future<void> replace( Future<void> replace(
List<ReminderNotice> notices, { List<ReminderNotice> notices, {
@ -29,9 +37,17 @@ class MemoryReminderGateway() implements ReminderGateway {
replaced++; replaced++;
} }
@override
Future<bool> notificationsAllowed() async => enabled;
@override @override
Future<bool> askPermission() async { Future<bool> askPermission() async {
asked++; asked++;
return allowed; return enabled = allowed;
}
@override
Future<void> openSettings() async {
openedSettings++;
} }
} }

View file

@ -131,7 +131,9 @@ the rail, a wash of strawberry, settings grouped as theirs. What the screenshots
there the running app shows them. Android brings them at the minute only where the there the running app shows them. Android brings them at the minute only where the
person allows exact alarms (`SCHEDULE_EXACT_ALARM`, off by default since Android 14), person allows exact alarms (`SCHEDULE_EXACT_ALARM`, off by default since Android 14),
else within a few minutes; the app doesn't send the person to that setting. The else within a few minutes; the app doesn't send the person to that setting. The
permission to notify is asked when a task first gets a reminder; a notification's permission to notify is asked when a task first gets a reminder (both amended by
D31: exact alarms through `USE_EXACT_ALARM`, the permission asked wherever a reminder
is planned); a notification's
text, the task's title, is hidden on a locked Android screen. Signing out, or text, the task's title, is hidden on a locked Android screen. Signing out, or
switching person, cancels the person's reminders on the device. Tapping one opens the switching person, cancels the person's reminders on the device. Tapping one opens the
app, not yet its task. app, not yet its task.
@ -560,6 +562,43 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
wide windows alone (a phone couldn't plan by dragging), a strip above the day wide windows alone (a phone couldn't plan by dragging), a strip above the day
(too little room for three groups), the project filter synced (a planning (too little room for three groups), the project filter synced (a planning
session's narrowing would follow the person to their other devices). session's narrowing would follow the person to their other devices).
- **D31 Reminders at the minute, and the permission to notify asked wherever one is
planned** (the developer's, 4 Nov 2026, after Todo's reminders never showed on a
phone, GrapheneOS on Android 14, installed afresh from F-Droid). Two faults: the
permission to notify (Android 13's `POST_NOTIFICATIONS`, iOS' and macOS' alert) was
asked only where a task's sheet chose its first reminder, so reminders typed in quick
add (`!30m`, `!1pm`), added by the automatic reminder (D4) or brought by sync were
planned and scheduled, and the system dropped every one without a word; and exact
alarms, `SCHEDULE_EXACT_ALARM`, are off from Android 14 on until the person allows
them in *Alarms & reminders*, so they came inexact, minutes late.
- **Asked wherever a reminder is planned**: whenever the plan of the device's
reminders holds one (`ReminderScheduler`), `NotificationAccess` reads whether the
device may notify (`ReminderGateway.notificationsAllowed`: Android's
`areNotificationsEnabled`, Apple's `checkPermissions`; Linux and Windows ask
nothing) and where it may not, asks, **once an app start**, the device's and not the
person's: refused, nobody is asked again and again (Android itself stops showing
its dialog after two refusals, iOS after one). The task sheet's ask goes through the
same place, so the plan's doesn't follow it.
- **Settings › Reminders says *Notifications are off*** while reminders are planned
and the device may not show them, with *Turn on*: asks again, and where that doesn't
allow it (refused again, or the system asks no more), opens the system's
notification settings of the app (the plugin's `openAppNotificationSettings`, no
package or platform code of Halkyon's added). Read again whenever the app comes
back, so notifications allowed there count at once. (Agent's choice: Turn on asks
first and opens the settings after a refusal, over telling the two apart with
Android's `shouldShowRequestPermissionRationale`, which would have needed a method
channel of the app's own for a case that ends in the settings either way.)
- **Exact alarms through `USE_EXACT_ALARM`** (the developer's choice), granted with the
install from Android 13 on to an app whose purpose is reminding of what a person
planned (calendars, alarm clocks, reminders), with `SCHEDULE_EXACT_ALARM` still
declared for Android 12 and 12L, which don't know the other, up to API 32 alone
(`test/android/exact_alarms_test.dart`). `AlarmManager.canScheduleExactAlarms` is
true with either, so the plugin's `canScheduleExactNotifications` is, and reminders
are `exactAllowWhileIdle`. *Alternatives:* leading the person to *Alarms &
reminders* (one more permission to ask and explain, which most would never find
again once refused), staying inexact (reminders minutes late, which a reminder at a
time can't be). Google Play reviews `USE_EXACT_ALARM` against that purpose; Todo is
one, and F-Droid asks nothing of it.
## Steps ## Steps