diff --git a/docs/SETUP.md b/docs/SETUP.md index 637cbe2..0f86274 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -41,6 +41,19 @@ Disable it explicitly: 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. + +Current default implementation is no-op: + +- `NoopReminderScheduler` in + `lib/features/reminders/scheduling/reminder_scheduler.dart` + +This keeps behavior stable across all platforms until real local notification +integration is added. + ## Local Persistence Backend Default local store: diff --git a/docs/progress.md b/docs/progress.md index 8f90463..3b33ac2 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,31 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Reminder Delivery Interface Wiring + +Implemented the notification scheduling boundary and connected it to local data +state transitions: + +- Added reminder scheduler abstraction: + - `lib/features/reminders/scheduling/reminder_scheduler.dart` + - `ReminderScheduler` + `NoopReminderScheduler` +- Added provider wiring: + - `lib/features/reminders/scheduling/reminder_scheduler_provider.dart` +- Integrated schedule reconciliation with local repository: + - `lib/features/local/local_repository.dart` + - reconcile runs on repository boot and each persisted state update + - failures are swallowed to preserve offline-first behavior +- Added tests: + - `test/features/local/local_repository_test.dart` now verifies reminder + schedule reconciliation calls +- Updated setup docs: + - `docs/SETUP.md` now documents reminder scheduler scaffold + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Sync Hardening + CI Baseline Implemented the next production-readiness slice: @@ -36,9 +61,9 @@ Validation for this milestone: ### Open Phases (Current) -1. Reminder delivery integration: - - add platform notification scheduler (local notifications) and wire reminder - CRUD/state changes to schedule/cancel/update jobs. +1. Reminder delivery runtime integration: + - replace no-op scheduler with platform local notifications implementation + for Android/iOS/macOS/Windows/Linux/Web-safe fallback. 2. Conflict resolution UX: - current UX shows and dismisses rejections; still missing guided resolve/retry/inspect flows per entity. diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 6a89ed3..d32daed 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -7,6 +7,7 @@ import 'package:relationship_saver/features/local/storage/local_data_store.dart' import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:uuid/uuid.dart'; @@ -41,6 +42,7 @@ class LocalRepository extends AsyncNotifier { if (record == null || record.rawState.isEmpty) { final LocalDataState seeded = LocalDataState.seed(); await _persist(seeded); + await _reconcileReminderSchedule(seeded); return seeded; } @@ -48,10 +50,13 @@ class LocalRepository extends AsyncNotifier { try { final Map json = jsonDecode(migrated.rawState) as Map; - return LocalDataState.fromJson(json); + final LocalDataState loaded = LocalDataState.fromJson(json); + await _reconcileReminderSchedule(loaded); + return loaded; } on FormatException { final LocalDataState fallback = LocalDataState.seed(); await _persist(fallback); + await _reconcileReminderSchedule(fallback); return fallback; } } @@ -889,6 +894,7 @@ class LocalRepository extends AsyncNotifier { Future _setState(LocalDataState next) async { state = AsyncData(next); await _persist(next); + await _reconcileReminderSchedule(next); } LocalDataState _requireState() { @@ -908,6 +914,14 @@ class LocalRepository extends AsyncNotifier { ); } + Future _reconcileReminderSchedule(LocalDataState state) async { + try { + await ref.read(reminderSchedulerProvider).reconcile(state.reminders); + } catch (_) { + // Scheduling failures must not block local-first data updates. + } + } + Future _migrateIfNeeded(LocalDataRecord record) async { final int currentVersion = record.schemaVersion; if (currentVersion == _schemaVersion) { diff --git a/lib/features/reminders/scheduling/reminder_scheduler.dart b/lib/features/reminders/scheduling/reminder_scheduler.dart new file mode 100644 index 0000000..054a5e9 --- /dev/null +++ b/lib/features/reminders/scheduling/reminder_scheduler.dart @@ -0,0 +1,21 @@ +import 'package:relationship_saver/features/local/local_models.dart'; + +/// Schedules and reconciles reminder deliveries for current local state. +abstract class ReminderScheduler { + /// Reconciles platform schedules to match current enabled reminders. + Future reconcile(List reminders); + + /// Clears all platform-scheduled reminder jobs. + Future clearAll(); +} + +/// Default scheduler used until platform notifications are integrated. +class NoopReminderScheduler implements ReminderScheduler { + const NoopReminderScheduler(); + + @override + Future clearAll() async {} + + @override + Future reconcile(List reminders) async {} +} diff --git a/lib/features/reminders/scheduling/reminder_scheduler_provider.dart b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart new file mode 100644 index 0000000..f293452 --- /dev/null +++ b/lib/features/reminders/scheduling/reminder_scheduler_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; + +/// Provides reminder delivery scheduler implementation. +final Provider reminderSchedulerProvider = + Provider((Ref ref) { + return const NoopReminderScheduler(); + }); diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index f991bbe..f5b8405 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -4,6 +4,8 @@ import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -175,13 +177,66 @@ void main() { ); expect(afterDelete.reminders.length, beforeCount); }); + + test('reconciles reminder schedule after state changes', () async { + final RecordingReminderScheduler scheduler = RecordingReminderScheduler(); + final ProviderContainer container = _createContainer(scheduler: scheduler); + addTearDown(container.dispose); + + final LocalDataState initial = await container.read( + localRepositoryProvider.future, + ); + final int beforeCount = initial.reminders.length; + + await container + .read(localRepositoryProvider.notifier) + .addReminder( + title: 'Scheduled reminder', + cadence: ReminderCadence.daily, + nextAt: DateTime.now().add(const Duration(hours: 4)), + ); + final LocalDataState afterAdd = await container.read( + localRepositoryProvider.future, + ); + + await container + .read(localRepositoryProvider.notifier) + .deleteReminder(afterAdd.reminders.first.id); + final LocalDataState afterDelete = await container.read( + localRepositoryProvider.future, + ); + + expect(scheduler.snapshots, isNotEmpty); + expect(scheduler.snapshots.first.length, beforeCount); + expect( + scheduler.snapshots.any( + (List r) => r.length == beforeCount + 1, + ), + isTrue, + ); + expect(scheduler.snapshots.last.length, afterDelete.reminders.length); + }); } -ProviderContainer _createContainer() { +ProviderContainer _createContainer({ReminderScheduler? scheduler}) { return ProviderContainer( overrides: [ localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + if (scheduler != null) + reminderSchedulerProvider.overrideWithValue(scheduler), ], ); } + +class RecordingReminderScheduler implements ReminderScheduler { + final List> snapshots = >[]; + + @override + Future clearAll() async {} + + @override + Future reconcile(List reminders) async { + snapshots.add(List.unmodifiable(reminders)); + } +}