Integrate local notifications scheduler for reminders
This commit is contained in:
+19
-7
@@ -46,16 +46,28 @@ flutter run --dart-define=ENABLE_BACKGROUND_SYNC=false
|
||||
|
||||
## Reminder Delivery Scaffold
|
||||
|
||||
Reminder scheduling is now wired through `ReminderScheduler` and reconciled from
|
||||
`LocalRepository` on startup/state changes.
|
||||
Reminder scheduling is now wired through `ReminderScheduler` and reconciled
|
||||
from `LocalRepository` on startup/state changes.
|
||||
|
||||
Current default implementation is no-op:
|
||||
Default implementation now uses local notifications runtime where supported.
|
||||
|
||||
- `NoopReminderScheduler` in
|
||||
`lib/features/reminders/scheduling/reminder_scheduler.dart`
|
||||
Control flag:
|
||||
|
||||
This keeps behavior stable across all platforms until real local notification
|
||||
integration is added.
|
||||
```bash
|
||||
flutter run --dart-define=ENABLE_LOCAL_NOTIFICATIONS=true
|
||||
```
|
||||
|
||||
Disable notifications explicitly:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=ENABLE_LOCAL_NOTIFICATIONS=false
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
|
||||
- web: no-op fallback
|
||||
- linux: schedule fallback (no-op) due platform scheduling limitations in plugin
|
||||
- android/iOS/macOS/windows: scheduled local notifications
|
||||
|
||||
## Local Persistence Backend
|
||||
|
||||
|
||||
+32
-2
@@ -2,6 +2,36 @@
|
||||
|
||||
Updated: 2026-02-15
|
||||
|
||||
## Latest Milestone (2026-02-15): Local Notifications Reminder Runtime
|
||||
|
||||
Implemented reminder delivery with local notifications runtime integration:
|
||||
|
||||
- Added dependency:
|
||||
- `flutter_local_notifications`
|
||||
- Added concrete scheduler:
|
||||
- `lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart`
|
||||
- `LocalNotificationsReminderScheduler`
|
||||
- reconciles local reminders into platform notification schedule
|
||||
- supports recurring cadence mapping:
|
||||
- daily -> `DateTimeComponents.time`
|
||||
- weekly -> `DateTimeComponents.dayOfWeekAndTime`
|
||||
- monthly -> `DateTimeComponents.dayOfMonthAndTime`
|
||||
- web/linux gracefully no-op (scheduling fallback)
|
||||
- Provider wiring now defaults to local notifications scheduler:
|
||||
- `lib/features/reminders/scheduling/reminder_scheduler_provider.dart`
|
||||
- gated by `ENABLE_LOCAL_NOTIFICATIONS`
|
||||
- Added config flag:
|
||||
- `lib/core/config/app_config.dart` -> `enableLocalNotifications`
|
||||
- Added tests:
|
||||
- `test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart`
|
||||
- Updated setup docs:
|
||||
- `docs/SETUP.md`
|
||||
|
||||
Validation for this milestone:
|
||||
|
||||
- `flutter analyze` -> pass
|
||||
- `flutter test` -> pass
|
||||
|
||||
## Latest Milestone (2026-02-15): Sync Rejection Payload Inspection
|
||||
|
||||
Improved conflict handling UX in Sync view:
|
||||
@@ -169,8 +199,8 @@ Validation for this milestone:
|
||||
### Open Phases (Current)
|
||||
|
||||
1. Reminder delivery runtime integration:
|
||||
- replace no-op scheduler with platform local notifications implementation
|
||||
for Android/iOS/macOS/Windows/Linux/Web-safe fallback.
|
||||
- implemented for Android/iOS/macOS/windows with web/linux safe fallback.
|
||||
- future enhancement: permission UX and 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.
|
||||
|
||||
@@ -30,6 +30,12 @@ class AppConfig {
|
||||
static bool get enableBackgroundSync =>
|
||||
const bool.fromEnvironment('ENABLE_BACKGROUND_SYNC', defaultValue: true);
|
||||
|
||||
/// Enables local notifications runtime for reminder delivery.
|
||||
static bool get enableLocalNotifications => const bool.fromEnvironment(
|
||||
'ENABLE_LOCAL_NOTIFICATIONS',
|
||||
defaultValue: true,
|
||||
);
|
||||
|
||||
/// Interval used for periodic background sync ticks.
|
||||
static int get backgroundSyncIntervalSeconds => const int.fromEnvironment(
|
||||
'BACKGROUND_SYNC_INTERVAL_SECONDS',
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
const String _channelId = 'relationship_saver_reminders';
|
||||
const String _channelName = 'Relationship Reminders';
|
||||
const String _channelDescription =
|
||||
'Reminders for relationship plans and follow-ups';
|
||||
|
||||
/// Schedules reminders with local notifications where platform support exists.
|
||||
class LocalNotificationsReminderScheduler implements ReminderScheduler {
|
||||
LocalNotificationsReminderScheduler({
|
||||
ReminderNotificationsDriver? driver,
|
||||
DateTime Function()? now,
|
||||
}) : _driver = driver ?? FlutterReminderNotificationsDriver(),
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final ReminderNotificationsDriver _driver;
|
||||
final DateTime Function() _now;
|
||||
|
||||
@override
|
||||
Future<void> clearAll() async {
|
||||
if (!_supportsSchedulingPlatform()) {
|
||||
return;
|
||||
}
|
||||
await _driver.ensureInitialized();
|
||||
await _driver.cancelAll();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reconcile(List<ReminderRule> reminders) async {
|
||||
if (!_supportsSchedulingPlatform()) {
|
||||
return;
|
||||
}
|
||||
await _driver.ensureInitialized();
|
||||
await _driver.cancelAll();
|
||||
|
||||
final DateTime now = _now();
|
||||
final List<ReminderRule> enabled = reminders
|
||||
.where((ReminderRule reminder) => reminder.enabled)
|
||||
.toList(growable: false);
|
||||
|
||||
for (final ReminderRule reminder in enabled) {
|
||||
final DateTimeComponents? repeating = _repeatComponents(reminder.cadence);
|
||||
if (repeating == null && reminder.nextAt.isBefore(now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await _driver.schedule(
|
||||
id: _notificationId(reminder.id),
|
||||
title: reminder.title,
|
||||
body: _buildBody(reminder),
|
||||
scheduledAt: reminder.nextAt,
|
||||
repeatComponents: repeating,
|
||||
payload: reminder.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
|
||||
return switch (cadence) {
|
||||
ReminderCadence.daily => DateTimeComponents.time,
|
||||
ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime,
|
||||
ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime,
|
||||
};
|
||||
}
|
||||
|
||||
String _buildBody(ReminderRule reminder) {
|
||||
final String frequency = switch (reminder.cadence) {
|
||||
ReminderCadence.daily => 'daily',
|
||||
ReminderCadence.weekly => 'weekly',
|
||||
ReminderCadence.monthly => 'monthly',
|
||||
};
|
||||
return 'Your $frequency reminder is due now.';
|
||||
}
|
||||
|
||||
bool _supportsSchedulingPlatform() {
|
||||
if (kIsWeb) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultTargetPlatform != TargetPlatform.linux;
|
||||
}
|
||||
|
||||
int _notificationId(String raw) {
|
||||
int hash = 0;
|
||||
for (final int unit in raw.codeUnits) {
|
||||
hash = ((hash * 31) + unit) & 0x7fffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver wrapper around the notification plugin for easier testing.
|
||||
abstract class ReminderNotificationsDriver {
|
||||
Future<void> ensureInitialized();
|
||||
|
||||
Future<void> cancelAll();
|
||||
|
||||
Future<void> schedule({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledAt,
|
||||
required DateTimeComponents? repeatComponents,
|
||||
required String payload,
|
||||
});
|
||||
}
|
||||
|
||||
class FlutterReminderNotificationsDriver
|
||||
implements ReminderNotificationsDriver {
|
||||
FlutterReminderNotificationsDriver({FlutterLocalNotificationsPlugin? plugin})
|
||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
Future<void>? _initializeFuture;
|
||||
|
||||
@override
|
||||
Future<void> ensureInitialized() {
|
||||
return _initializeFuture ??= _initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelAll() async {
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> schedule({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledAt,
|
||||
required DateTimeComponents? repeatComponents,
|
||||
required String payload,
|
||||
}) async {
|
||||
await _plugin.zonedSchedule(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
|
||||
notificationDetails: _notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
matchDateTimeComponents: repeatComponents,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await _plugin.initialize(
|
||||
settings: const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(),
|
||||
macOS: DarwinInitializationSettings(),
|
||||
linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'),
|
||||
windows: WindowsInitializationSettings(
|
||||
appName: 'Relationship Saver',
|
||||
appUserModelId: 'com.relationshipsaver.app',
|
||||
guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
NotificationDetails get _notificationDetails {
|
||||
return const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
macOS: DarwinNotificationDetails(),
|
||||
linux: LinuxNotificationDetails(),
|
||||
windows: WindowsNotificationDetails(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:relationship_saver/core/config/app_config.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
|
||||
|
||||
/// Provides reminder delivery scheduler implementation.
|
||||
final Provider<ReminderScheduler> reminderSchedulerProvider =
|
||||
Provider<ReminderScheduler>((Ref ref) {
|
||||
if (AppConfig.enableLocalNotifications) {
|
||||
return LocalNotificationsReminderScheduler();
|
||||
}
|
||||
return const NoopReminderScheduler();
|
||||
});
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import shared_preferences_foundation
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
}
|
||||
|
||||
@@ -185,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -246,6 +254,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "20.1.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -600,6 +640,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -869,6 +917,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.15"
|
||||
timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -965,6 +1021,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -43,6 +43,8 @@ dependencies:
|
||||
flutter_riverpod: ^3.2.1
|
||||
shared_preferences: ^2.5.4
|
||||
hive_flutter: ^1.1.0
|
||||
timezone: ^0.10.1
|
||||
flutter_local_notifications: ^20.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'reconcile schedules enabled reminders and cancels previous jobs',
|
||||
() async {
|
||||
final FakeReminderNotificationsDriver driver =
|
||||
FakeReminderNotificationsDriver();
|
||||
final LocalNotificationsReminderScheduler scheduler =
|
||||
LocalNotificationsReminderScheduler(
|
||||
driver: driver,
|
||||
now: () => DateTime(2026, 2, 16, 10),
|
||||
);
|
||||
|
||||
await scheduler.reconcile(<ReminderRule>[
|
||||
ReminderRule(
|
||||
id: 'r-daily',
|
||||
title: 'Daily check-in',
|
||||
cadence: ReminderCadence.daily,
|
||||
nextAt: DateTime(2026, 2, 16, 20),
|
||||
),
|
||||
ReminderRule(
|
||||
id: 'r-weekly',
|
||||
title: 'Weekly date plan',
|
||||
cadence: ReminderCadence.weekly,
|
||||
nextAt: DateTime(2026, 2, 17, 9),
|
||||
),
|
||||
ReminderRule(
|
||||
id: 'r-disabled',
|
||||
title: 'Disabled reminder',
|
||||
cadence: ReminderCadence.monthly,
|
||||
nextAt: DateTime(2026, 2, 18, 8),
|
||||
enabled: false,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(driver.initializeCalls, 1);
|
||||
expect(driver.cancelAllCalls, 1);
|
||||
expect(driver.scheduled.length, 2);
|
||||
expect(driver.scheduled[0].repeatComponents, DateTimeComponents.time);
|
||||
expect(
|
||||
driver.scheduled[1].repeatComponents,
|
||||
DateTimeComponents.dayOfWeekAndTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('clearAll clears notification schedule', () async {
|
||||
final FakeReminderNotificationsDriver driver =
|
||||
FakeReminderNotificationsDriver();
|
||||
final LocalNotificationsReminderScheduler scheduler =
|
||||
LocalNotificationsReminderScheduler(driver: driver);
|
||||
|
||||
await scheduler.clearAll();
|
||||
|
||||
expect(driver.initializeCalls, 1);
|
||||
expect(driver.cancelAllCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class FakeReminderNotificationsDriver implements ReminderNotificationsDriver {
|
||||
int initializeCalls = 0;
|
||||
int cancelAllCalls = 0;
|
||||
final List<ScheduledCall> scheduled = <ScheduledCall>[];
|
||||
|
||||
@override
|
||||
Future<void> cancelAll() async {
|
||||
cancelAllCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> ensureInitialized() async {
|
||||
initializeCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> schedule({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledAt,
|
||||
required DateTimeComponents? repeatComponents,
|
||||
required String payload,
|
||||
}) async {
|
||||
scheduled.add(
|
||||
ScheduledCall(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
scheduledAt: scheduledAt,
|
||||
repeatComponents: repeatComponents,
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduledCall {
|
||||
const ScheduledCall({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.scheduledAt,
|
||||
required this.repeatComponents,
|
||||
required this.payload,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String title;
|
||||
final String body;
|
||||
final DateTime scheduledAt;
|
||||
final DateTimeComponents? repeatComponents;
|
||||
final String payload;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user