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:
parent
d49331b210
commit
82f1bf2739
16 changed files with 458 additions and 33 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)", () {
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue