Integrate local notifications scheduler for reminders

This commit is contained in:
Rijad Zuzo
2026-02-15 23:48:29 +01:00
parent 960a891eba
commit 6e73273ede
10 changed files with 430 additions and 9 deletions
+19 -7
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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"))
}
+64
View File
@@ -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:
+2
View File
@@ -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;
}
+1
View File
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
)
set(PLUGIN_BUNDLED_LIBRARIES)