Add notification permission flow in settings
This commit is contained in:
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user