From 6e73273ede79367ac2cd497ad3faa800981ae151 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 23:48:29 +0100 Subject: [PATCH] Integrate local notifications scheduler for reminders --- docs/SETUP.md | 26 ++- docs/progress.md | 34 +++- lib/core/config/app_config.dart | 6 + ...eminder_scheduler_local_notifications.dart | 182 ++++++++++++++++++ .../reminder_scheduler_provider.dart | 5 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 64 ++++++ pubspec.yaml | 2 + ...er_scheduler_local_notifications_test.dart | 117 +++++++++++ windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 430 insertions(+), 9 deletions(-) create mode 100644 lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart create mode 100644 test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart diff --git a/docs/SETUP.md b/docs/SETUP.md index 234580b..18c9f14 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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 diff --git a/docs/progress.md b/docs/progress.md index f7f5ed7..94954f5 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index f97d34d..c7bbe77 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -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', diff --git a/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart new file mode 100644 index 0000000..60e46f8 --- /dev/null +++ b/lib/features/reminders/scheduling/reminder_scheduler_local_notifications.dart @@ -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 clearAll() async { + if (!_supportsSchedulingPlatform()) { + return; + } + await _driver.ensureInitialized(); + await _driver.cancelAll(); + } + + @override + Future reconcile(List reminders) async { + if (!_supportsSchedulingPlatform()) { + return; + } + await _driver.ensureInitialized(); + await _driver.cancelAll(); + + final DateTime now = _now(); + final List 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 ensureInitialized(); + + Future cancelAll(); + + Future 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? _initializeFuture; + + @override + Future ensureInitialized() { + return _initializeFuture ??= _initialize(); + } + + @override + Future cancelAll() async { + await _plugin.cancelAll(); + } + + @override + Future 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 _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(), + ); + } +} diff --git a/lib/features/reminders/scheduling/reminder_scheduler_provider.dart b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart index f293452..79c70a3 100644 --- a/lib/features/reminders/scheduling/reminder_scheduler_provider.dart +++ b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart @@ -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 reminderSchedulerProvider = Provider((Ref ref) { + if (AppConfig.enableLocalNotifications) { + return LocalNotificationsReminderScheduler(); + } return const NoopReminderScheduler(); }); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ee71999..157bb5d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index acb58ff..d7e7dc6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 2ff530a..ecce72e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart b/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart new file mode 100644 index 0000000..5a77533 --- /dev/null +++ b/test/features/reminders/scheduling/reminder_scheduler_local_notifications_test.dart @@ -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( + 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 scheduled = []; + + @override + Future cancelAll() async { + cancelAllCalls += 1; + } + + @override + Future ensureInitialized() async { + initializeCalls += 1; + } + + @override + Future 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; +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fc759c..197d43d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)