Add notification permission flow in settings

This commit is contained in:
Rijad Zuzo
2026-02-15 23:53:46 +01:00
parent 6e73273ede
commit 560716100d
7 changed files with 144 additions and 1 deletions
+2
View File
@@ -68,6 +68,8 @@ Behavior notes:
- web: no-op fallback - web: no-op fallback
- linux: schedule fallback (no-op) due platform scheduling limitations in plugin - linux: schedule fallback (no-op) due platform scheduling limitations in plugin
- android/iOS/macOS/windows: scheduled local notifications - android/iOS/macOS/windows: scheduled local notifications
- settings screen includes `Request Notification Access` action for runtime
permission prompts
## Local Persistence Backend ## Local Persistence Backend
+25 -1
View File
@@ -2,6 +2,29 @@
Updated: 2026-02-15 Updated: 2026-02-15
## Latest Milestone (2026-02-15): Notification Permission UX
Added runtime notification permission flow through the product UI:
- Extended reminder scheduler interface:
- `lib/features/reminders/scheduling/reminder_scheduler.dart`
- new `requestPermissions()` API
- Implemented permission handling in local notifications scheduler:
- `lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart`
- Android: `requestNotificationsPermission()`
- iOS/macOS: `requestPermissions(alert/badge/sound)`
- Added settings action:
- `lib/features/settings/settings_view.dart`
- `Request Notification Access` button with result feedback snackbar
- Added test coverage:
- `test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart`
- verifies permission delegation behavior
Validation for this milestone:
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-15): Local Notifications Reminder Runtime ## Latest Milestone (2026-02-15): Local Notifications Reminder Runtime
Implemented reminder delivery with local notifications runtime integration: Implemented reminder delivery with local notifications runtime integration:
@@ -200,7 +223,8 @@ Validation for this milestone:
1. Reminder delivery runtime integration: 1. Reminder delivery runtime integration:
- implemented for Android/iOS/macOS/windows with web/linux safe fallback. - implemented for Android/iOS/macOS/windows with web/linux safe fallback.
- future enhancement: permission UX and direct notification tap routing. - permission UX implemented in settings.
- future enhancement: direct notification tap routing.
2. Conflict resolution UX: 2. Conflict resolution UX:
- reject/requeue + payload inspect are implemented; future enhancement is - reject/requeue + payload inspect are implemented; future enhancement is
direct deep-link navigation to entity edit screens from Sync view. direct deep-link navigation to entity edit screens from Sync view.
@@ -7,6 +7,9 @@ abstract class ReminderScheduler {
/// Clears all platform-scheduled reminder jobs. /// Clears all platform-scheduled reminder jobs.
Future<void> clearAll(); Future<void> clearAll();
/// Requests notification permissions where the platform requires them.
Future<bool> requestPermissions();
} }
/// Default scheduler used until platform notifications are integrated. /// Default scheduler used until platform notifications are integrated.
@@ -18,4 +21,7 @@ class NoopReminderScheduler implements ReminderScheduler {
@override @override
Future<void> reconcile(List<ReminderRule> reminders) async {} Future<void> reconcile(List<ReminderRule> reminders) async {}
@override
Future<bool> requestPermissions() async => true;
} }
@@ -59,6 +59,15 @@ class LocalNotificationsReminderScheduler implements ReminderScheduler {
} }
} }
@override
Future<bool> requestPermissions() async {
if (!_supportsSchedulingPlatform()) {
return false;
}
await _driver.ensureInitialized();
return _driver.requestPermissions();
}
DateTimeComponents? _repeatComponents(ReminderCadence cadence) { DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
return switch (cadence) { return switch (cadence) {
ReminderCadence.daily => DateTimeComponents.time, ReminderCadence.daily => DateTimeComponents.time,
@@ -107,6 +116,8 @@ abstract class ReminderNotificationsDriver {
required DateTimeComponents? repeatComponents, required DateTimeComponents? repeatComponents,
required String payload, required String payload,
}); });
Future<bool> requestPermissions();
} }
class FlutterReminderNotificationsDriver class FlutterReminderNotificationsDriver
@@ -148,6 +159,38 @@ class FlutterReminderNotificationsDriver
); );
} }
@override
Future<bool> requestPermissions() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
return true;
}
Future<void> _initialize() async { Future<void> _initialize() async {
await _plugin.initialize( await _plugin.initialize(
settings: const InitializationSettings( settings: const InitializationSettings(
+42
View File
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart'; import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
@@ -74,6 +75,14 @@ class SettingsView extends ConsumerWidget {
label: 'Realtime', label: 'Realtime',
value: 'Planned later (SSE/WebSocket)', value: 'Planned later (SSE/WebSocket)',
), ),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Local notifications',
value: AppConfig.enableLocalNotifications
? 'Enabled'
: 'Disabled',
),
const SizedBox(height: 14), const SizedBox(height: 14),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -99,6 +108,18 @@ class SettingsView extends ConsumerWidget {
icon: const Icon(Icons.refresh_rounded), icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh Profile'), label: const Text('Refresh Profile'),
), ),
OutlinedButton.icon(
onPressed: AppConfig.enableLocalNotifications
? () => _requestNotificationPermissions(
context,
ref,
)
: null,
icon: const Icon(
Icons.notifications_active_rounded,
),
label: const Text('Request Notification Access'),
),
], ],
), ),
], ],
@@ -127,6 +148,27 @@ class SettingsView extends ConsumerWidget {
).showSnackBar(const SnackBar(content: Text('Signed out.'))); ).showSnackBar(const SnackBar(content: Text('Signed out.')));
} }
} }
Future<void> _requestNotificationPermissions(
BuildContext context,
WidgetRef ref,
) async {
final bool granted = await ref
.read(reminderSchedulerProvider)
.requestPermissions();
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
granted
? 'Notification access granted.'
: 'Notification access not granted.',
),
),
);
}
} }
class _SettingRow extends StatelessWidget { class _SettingRow extends StatelessWidget {
@@ -235,6 +235,9 @@ class RecordingReminderScheduler implements ReminderScheduler {
@override @override
Future<void> clearAll() async {} Future<void> clearAll() async {}
@override
Future<bool> requestPermissions() async => true;
@override @override
Future<void> reconcile(List<ReminderRule> reminders) async { Future<void> reconcile(List<ReminderRule> reminders) async {
snapshots.add(List<ReminderRule>.unmodifiable(reminders)); snapshots.add(List<ReminderRule>.unmodifiable(reminders));
@@ -59,12 +59,29 @@ void main() {
expect(driver.initializeCalls, 1); expect(driver.initializeCalls, 1);
expect(driver.cancelAllCalls, 1); expect(driver.cancelAllCalls, 1);
}); });
test('requestPermissions delegates to notifications driver', () async {
final FakeReminderNotificationsDriver driver =
FakeReminderNotificationsDriver(permissionResult: true);
final LocalNotificationsReminderScheduler scheduler =
LocalNotificationsReminderScheduler(driver: driver);
final bool granted = await scheduler.requestPermissions();
expect(granted, isTrue);
expect(driver.initializeCalls, 1);
expect(driver.permissionRequests, 1);
});
} }
class FakeReminderNotificationsDriver implements ReminderNotificationsDriver { class FakeReminderNotificationsDriver implements ReminderNotificationsDriver {
FakeReminderNotificationsDriver({this.permissionResult = true});
int initializeCalls = 0; int initializeCalls = 0;
int cancelAllCalls = 0; int cancelAllCalls = 0;
int permissionRequests = 0;
final List<ScheduledCall> scheduled = <ScheduledCall>[]; final List<ScheduledCall> scheduled = <ScheduledCall>[];
final bool permissionResult;
@override @override
Future<void> cancelAll() async { Future<void> cancelAll() async {
@@ -96,6 +113,12 @@ class FakeReminderNotificationsDriver implements ReminderNotificationsDriver {
), ),
); );
} }
@override
Future<bool> requestPermissions() async {
permissionRequests += 1;
return permissionResult;
}
} }
class ScheduledCall { class ScheduledCall {