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
15 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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
/// 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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)", () {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue