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
15 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",
"snoozeMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
"reminderSnooze": "{count} Min. schlummern",
"notificationsOffTitle": "Benachrichtigungen sind aus",
"notificationsOffHint": "Erinnerungen erscheinen erst, wenn Halkyon Todo dich benachrichtigen darf.",
"notificationsTurnOn": "Einschalten",
"monthMore": "{count} weitere",
"dayHeadingPattern": "EEEE, d. MMM",
"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": {
"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'**
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.
///
/// In en, this message translates to:

View file

@ -1006,6 +1006,16 @@ class AppLocalizationsDe extends AppLocalizations {
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
String monthMore(int count) {
return '$count weitere';

View file

@ -1006,6 +1006,16 @@ class AppLocalizationsEn extends AppLocalizations {
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
String monthMore(int count) {
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/sync_project_gateway.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/system_reminder_gateway.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
/// 0033, D5); none in tests that don't say.
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,
}) 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
/// sub-projects with theirs, as Todoist does (ADR 0033, D28); throws
/// [InboxKept] for the Inbox, which keeps everything.
@ -274,6 +283,7 @@ class TodoDependencies({
await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId),
reminders: device.reminders,
notifications: device.notifications,
onClosed: closed.complete,
);
} on Object {
@ -311,6 +321,7 @@ Future<TodoPerson> _openPersonData(
Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys,
required ReminderGateway reminders,
required NotificationAccess notifications,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -368,6 +379,7 @@ Future<TodoPerson> _openPersonData(
labels: labels,
settings: settings,
reminders: reminders,
notifications: notifications,
onDispose: () async {
try {
await projects.dispose();
@ -405,6 +417,10 @@ class TodoDevice({
/// The system's notifications, where reminders go (ADR 0033, D5).
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.
static Future<TodoDevice> create({required Instance builtIn}) async {
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,
});
/// 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; whether it may.
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.
@ -30,6 +38,13 @@ class const NoReminders() implements ReminderGateway {
({int minutes, String label})? snooze,
}) async {}
/// Nothing to turn on: nothing is shown either way.
@override
Future<bool> notificationsAllowed() async => true;
@override
Future<bool> askPermission() async => false;
@override
Future<void> openSettings() async {}
}

View file

@ -201,7 +201,7 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
onDidReceiveBackgroundNotificationResponse: snoozeInBackground,
settings: InitializationSettings(
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(
requestAlertPermission: false,
requestBadgePermission: false,
@ -291,14 +291,14 @@ class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
}
}
/// 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.
/// Whether Android lets the app wake at the minute (ADR 0033, D31): on
/// Android 13 on through `USE_EXACT_ALARM`, granted with the install to
/// 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 {
final android = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
final android = _android;
return android == null ||
(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
Future<bool> askPermission() async {
try {
return switch (defaultTargetPlatform) {
TargetPlatform.android =>
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false,
await _android?.requestNotificationsPermission() ?? false,
TargetPlatform.iOS =>
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
await _iOS?.requestPermissions(alert: true, sound: true) ?? false,
TargetPlatform.macOS =>
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
await _macOS?.requestPermissions(alert: true, sound: true) ?? false,
_ => true,
};
} 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,
/// 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
/// 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})
extends StatefulWidget {
@override
@ -57,6 +59,14 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
final plan = planReminders([
for (final task in state.tasks) StoredTask(task.id, task.content),
], 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;
if (listEquals(plan, _planned) && snooze == _snooze) {
return;

View file

@ -840,9 +840,10 @@ class _TaskSheetState() extends State<_TaskSheet> {
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.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);
if (!await reminders.askPermission()) {
if (!await notifications.ask()) {
messenger.showSnackBar(
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
/// 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 {
@override
Widget build(BuildContext context) {
@ -94,6 +96,7 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
return _Card(
title: l10n.remindersTitle,
children: [
const _NotificationsOff(),
ListTile(
leading: const Icon(LucideIcons.alarmClockPlus),
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].
class const _Card({
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)", () {

View file

@ -15,9 +15,17 @@ class MemoryReminderGateway() implements ReminderGateway {
/// What the person answers when asked.
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.
int asked = 0;
/// How often the system's notification settings of the app were opened.
int openedSettings = 0;
@override
Future<void> replace(
List<ReminderNotice> notices, {
@ -29,9 +37,17 @@ class MemoryReminderGateway() implements ReminderGateway {
replaced++;
}
@override
Future<bool> notificationsAllowed() async => enabled;
@override
Future<bool> askPermission() async {
asked++;
return allowed;
return enabled = allowed;
}
@override
Future<void> openSettings() async {
openedSettings++;
}
}