diff --git a/docs/SETUP.md b/docs/SETUP.md index 18c9f14..2762fda 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -68,6 +68,8 @@ Behavior notes: - web: no-op fallback - linux: schedule fallback (no-op) due platform scheduling limitations in plugin - android/iOS/macOS/windows: scheduled local notifications +- settings screen includes `Request Notification Access` action for runtime + permission prompts ## Local Persistence Backend diff --git a/docs/progress.md b/docs/progress.md index 94954f5..f0a9caa 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,29 @@ 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 Implemented reminder delivery with local notifications runtime integration: @@ -200,7 +223,8 @@ Validation for this milestone: 1. Reminder delivery runtime integration: - 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: - reject/requeue + payload inspect are implemented; future enhancement is direct deep-link navigation to entity edit screens from Sync view. diff --git a/lib/features/reminders/scheduling/reminder_scheduler.dart b/lib/features/reminders/scheduling/reminder_scheduler.dart index 054a5e9..9a650d9 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler.dart @@ -7,6 +7,9 @@ abstract class ReminderScheduler { /// Clears all platform-scheduled reminder jobs. Future clearAll(); + + /// Requests notification permissions where the platform requires them. + Future requestPermissions(); } /// Default scheduler used until platform notifications are integrated. @@ -18,4 +21,7 @@ class NoopReminderScheduler implements ReminderScheduler { @override Future reconcile(List reminders) async {} + + @override + Future requestPermissions() async => true; } diff --git a/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart index 60e46f8..8c6e501 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart @@ -59,6 +59,15 @@ class LocalNotificationsReminderScheduler implements ReminderScheduler { } } + @override + Future requestPermissions() async { + if (!_supportsSchedulingPlatform()) { + return false; + } + await _driver.ensureInitialized(); + return _driver.requestPermissions(); + } + DateTimeComponents? _repeatComponents(ReminderCadence cadence) { return switch (cadence) { ReminderCadence.daily => DateTimeComponents.time, @@ -107,6 +116,8 @@ abstract class ReminderNotificationsDriver { required DateTimeComponents? repeatComponents, required String payload, }); + + Future requestPermissions(); } class FlutterReminderNotificationsDriver @@ -148,6 +159,38 @@ class FlutterReminderNotificationsDriver ); } + @override + Future 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 _initialize() async { await _plugin.initialize( settings: const InitializationSettings( diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index ca90782..7f9ebd1 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -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_theme.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/integrations/backend/models/backend_models.dart'; @@ -74,6 +75,14 @@ class SettingsView extends ConsumerWidget { label: 'Realtime', value: 'Planned later (SSE/WebSocket)', ), + const SizedBox(height: 12), + _SettingRow( + compact: compact, + label: 'Local notifications', + value: AppConfig.enableLocalNotifications + ? 'Enabled' + : 'Disabled', + ), const SizedBox(height: 14), Wrap( spacing: 8, @@ -99,6 +108,18 @@ class SettingsView extends ConsumerWidget { icon: const Icon(Icons.refresh_rounded), 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.'))); } } + + Future _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 { diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index f5b8405..8deb968 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -235,6 +235,9 @@ class RecordingReminderScheduler implements ReminderScheduler { @override Future clearAll() async {} + @override + Future requestPermissions() async => true; + @override Future reconcile(List reminders) async { snapshots.add(List.unmodifiable(reminders)); diff --git a/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart b/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart index 5a77533..eaea474 100644 --- a/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart +++ b/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart @@ -59,12 +59,29 @@ void main() { expect(driver.initializeCalls, 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 { + FakeReminderNotificationsDriver({this.permissionResult = true}); + int initializeCalls = 0; int cancelAllCalls = 0; + int permissionRequests = 0; final List scheduled = []; + final bool permissionResult; @override Future cancelAll() async { @@ -96,6 +113,12 @@ class FakeReminderNotificationsDriver implements ReminderNotificationsDriver { ), ); } + + @override + Future requestPermissions() async { + permissionRequests += 1; + return permissionResult; + } } class ScheduledCall {