Local notifications scheduler
Skill zakariaf/Flutter-Skills/skills/local-notifications-scheduler
Enforces an on-device reminder engine where the local database is the only source of truth and the OS pending-notification set is a disposable cache reconciled through one idempotent syncNotifications() entrypoint; keeps flutter_local_notifications behind a single NotificationGateway port, all scheduling math pure and Clock-injected, recurring schedules stored as wall-clock + recurrence rule (never UTC instants) and resolved to a TZDateTime in tz.local for DST correctness, inexact-alarm default with SCHEDULE_EXACT_ALARM as an opt-in, iOS ~64-cap budgeting, isolate-safe @pragma('vm:entry-point') tap handlers, and Android boot re-arm. Use when writing or editing notification_gateway.dart, fln_notification_gateway.dart, reminder_scheduler.dart, recurrence_rule.dart, syncNotifications, zonedSchedule wiring, snooze or mark-done re-anchoring, or diagnosing missed or wrong-hour notifications.From its SKILL.md
npx -y skills add zakariaf/Flutter-Skills --skill local-notifications-schedulerAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 26 days oldThe repository was created 26 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
15.1 KB, ~3.2k tokens by cl100k_base, as published. Nobody here has run it
Local Notifications Scheduler
One on-device reminder engine, owned centrally, with a narrow API. The local DB is the source of truth; the OS pending set is a disposable cache. Every scheduling change flows through one pure-then-reconcile path so the whole engine is verifiable off-device. Applies whenever you schedule, cancel, or reason about local notifications.
Read the reference for the task at hand:
references/notification-gateway-port.md— the port contract, the two adapters, channels/grouping/deep-link payload rules.references/scheduler-and-recurrence-rules.md— pureReminderScheduler.computediff, recurrence/DST math, deterministic IDs, budgeting, the full test matrix.references/platform-boot-exact-alarms-oem.md— boot re-arm, exact-alarm degradation, background-isolate safety, Android manifest/Gradle, the OEM survival matrix.
Run scripts/check-single-fln-import.sh, scripts/check-scheduler-purity.sh, scripts/check-adhoc-schedule-calls.sh, and scripts/check-manifest-permissions.sh before a PR.
Non-negotiable rules
- The local DB is the ONLY source of truth. The OS pending-notification set is a disposable cache you reconcile against — never the store of record. Every reminder must be reconstructible from the DB alone after process death, reboot, Doze, or restore.
- Route EVERY scheduling change through one
syncNotifications()reconcile entrypoint. Never callgateway.schedule()/cancel()ad hoc from feature or UI code — that path cannot be made idempotent and desyncs the cache. - Import
flutter_local_notificationsin exactly one file — the FLN adapter. Everything else, including all tests, talks to theNotificationGatewayport. A grep gate fails the build on any other import. - Keep all scheduling math in pure, side-effect-free classes. No plugin calls, no IO, no
DateTime.now()inside them — inject aClockfrompackage:clock: Riverpod code reads it fromclockProvider, the pure math takes it as a parameter (neverDateTime.now(), never a bespokeClockService). Purity is what makes off-device unit testing viaFakeNotificationGatewaypossible. - Store recurring schedules as wall-clock + recurrence rule, resolve to
TZDateTimeonly at schedule time. Never persist a recurring schedule as a UTC instant — it drifts an hour across every DST boundary. True one-off instants stay UTC epoch millis. - Set
tz.localat startup (fromflutter_timezone). Thetimezonepackage defaults to UTC — forget this and everyzonedSchedulefires at the wrong local hour. - Default to
AndroidScheduleMode.inexactAllowWhileIdle(permission-free, pierces Doze). Gate exact firing behind the user-revocableSCHEDULE_EXACT_ALARMviacanScheduleExactAlarms()with silent fallback to inexact. Never declareUSE_EXACT_ALARM— Play policy restricts it to alarm/timer/calendar apps and risks store rejection. - Budget to ~50 pending on iOS (headroom under the silent 64-cap). Sort future instants ascending, take the nearest ~50, refill on every foreground. The 65th+ silently never fires, with no error.
- Use deterministic IDs derived from
reminderId + occurrenceIndexplus a hash of the resolved fire instant and notification content.getPending()exposes only theid, notwhen— so if the id ignored the fire time, editing a reminder without changing its occurrence (a one-off moved 09:00→14:00, or a daily rule's hour changed) would keep the same id, be skipped by BOTH the cancel and schedule loops, and fire at the OLD time. Folding the resolvedTZDateTime(and content) into the id makes an edit produce a NEW id — old id cancelled, new id scheduled. Unchanged input still maps to the same id, so reconcile stays a no-op. - The app-foreground reconcile is the reliability backbone. Boot receiver, background ticks, exact-alarm toggle, and OEM survival are all explicitly best-effort. With no server push there is no way to learn a reminder was dropped except the next foreground reconcile diff — that constraint is why this architecture exists, not a gap to paper over.
Package / folder layout
The engine is shared infrastructure, not a feature — the pure math lives in core/ (no Flutter, off-device testable) and the side-effect port + live impl + reconcile live in services/. project-structure-and-packages owns this tree.
lib/
core/
notifications/ # PURE — no Flutter, off-device testable
reminder_scheduler.dart # compute(desired) -> reconcile diff
recurrence_rule.dart # wall-clock + rule -> TZDateTime
scheduled_notification.dart # value objects (ScheduledNotification, PendingNotification)
deterministic_id.dart # reminderId + occurrence + resolved instant/content -> id
services/
notifications/ # the side-effect port, live adapter, and reconcile
notification_gateway.dart # abstract PORT: schedule/cancel/cancelAll/getPending
fln_notification_gateway.dart # ONLY file importing flutter_local_notifications
fake_notification_gateway.dart # in-memory list, for tests
sync_notifications.dart # the single reconcile entrypoint
boot_rearm_android.dart # BOOT_COMPLETED post-unlock re-arm
When multi-package (workspace): promote the pure math + gateway into a
packages/notificationspackage (wherelib/src/+ one public barrel is the sanctioned convention) so the ~N features that feed it cannot diverge. In a single-package app thecore/+services/split is enough. Seeproject-structure-and-packages.
Wire construction through Riverpod providers — never global singletons or per-feature instances. The Clock comes from clockProvider (Clock.fixed(...) in tests); see service-boundary-and-native for the throws-until-overridden port provider pattern.
The canonical reconcile (the backbone)
compute is pure; the gateway diff is the only IO. This is the ONE entrypoint every scheduling change flows through.
Future<void> syncNotifications(
NotificationGateway gw,
Clock clock,
ReminderRepository repo,
) async {
final desired = ReminderScheduler.compute( // PURE — no IO, injected clock
reminders: await repo.activeReminders(), // from the local DB (source of truth)
now: clock.now(),
budget: 50, // headroom under the silent iOS 64-cap
); // sorts future instants ascending,
// takes the nearest ~50 as a rolling window
final current = await gw.getPending();
final desiredIds = {for (final d in desired) d.id};
final currentIds = {for (final c in current) c.id};
for (final c in current) { // cancel stale
if (!desiredIds.contains(c.id)) await gw.cancel(c.id);
}
for (final d in desired) { // schedule new / changed
if (!currentIds.contains(d.id)) await gw.schedule(d);
}
}
Call syncNotifications() on: app foreground/resume, reminder CRUD, backup import/restore, exact-alarm permission granted, Android boot, and the daily background tick. See examples/sync_notifications.dart for the annotated version including the after-restore path.
The gateway port
The port isolates the plugin so all scheduling math and all tests stay off-device.
abstract class NotificationGateway {
Future<void> schedule(ScheduledNotification n);
Future<void> cancel(int id);
Future<void> cancelAll();
Future<List<PendingNotification>> getPending();
}
Keep it to exactly these four operations — anything more is a signal the logic belongs in the pure ReminderScheduler. The concrete FlnNotificationGateway is the single file importing flutter_local_notifications, timezone, and flutter_timezone; the FakeNotificationGateway is an in-memory List. Wire the concrete one through a Riverpod provider that tests override with the fake in a ProviderContainer. Full contract and the ScheduledNotification value object: references/notification-gateway-port.md and examples/notification_gateway.dart.
Recurrence & DST
Resolve wall-clock + rule to a TZDateTime in tz.local at schedule time — DST-correct by construction.
// Wall-clock 9:00 daily -> concrete next fire instant, DST-safe.
tz.TZDateTime nextDailyAt(int hour, int minute, {required Clock clock}) {
final now = tz.TZDateTime.from(clock.now(), tz.local);
var next = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
if (!next.isAfter(now)) next = next.add(const Duration(days: 1));
return next;
}
Genuinely calendar-recurring items may use one repeating notification (matchDateTimeComponents) consuming a single slot; everything else is a one-shot recomputed each reconcile. Mark-done re-anchoring: compute the next occurrence from the actual completion time, not the scheduled one, so recurring reminders don't drift. Never store "9am" as a UTC instant.
Some apps derive a reminder time from a non-time trigger (e.g. "remind after N completions"). Keep that projection in a pure, Clock-injected helper that resolves to a concrete future instant before it reaches the scheduler, so the scheduler still handles one homogeneous list of instants. Debounce re-projection so a stream of inputs doesn't cause a reschedule storm.
Background isolate & tap handling
The background tap/action handler needs @pragma('vm:entry-point') and runs in a separate isolate with no main-isolate state. Do not write to the DB from it — record a lightweight "pending action" intent (or nothing) and let the next foreground reconcile do the real work, avoiding concurrent DB access from two isolates. A tap maps a serializable payload → a go_router location reconstructed from the DB — never a non-serializable extra. See async-safety for mounted/context guards after the await, and references/platform-boot-exact-alarms-oem.md.
Localization & backup
Notification titles/bodies are localized through gen-l10n with ICU plurals and numeral normalization; the schedule instant stays a Gregorian TZDateTime regardless of display calendar (see i18n-rtl-l10n). Backup does not carry OS notification state or exact-alarm grants — after import, run cancelAll() then a full reconcile; never assume restored pending IDs are valid.
Anti-patterns
- Calling
gateway.schedule()from a feature/ViewModel. Desyncs the cache and can't be made idempotent. Route throughsyncNotifications(). - Persisting a recurring schedule as a UTC instant. Shifts the fire time by an hour across every DST boundary. Store wall-clock + rule.
DateTime.now()inside the scheduler. Kills off-device testability. Inject aClock.- Declaring
USE_EXACT_ALARMto "just make it fire on time." Play-policy rejection risk. Use theSCHEDULE_EXACT_ALARMopt-in with inexact fallback. - Scheduling an unbounded set. iOS silently drops past 64. Budget and refill.
- Writing to the DB from the tap isolate. Concurrent access risks corruption. Record intent; reconcile on foreground.
- Trusting a green emulator run for reboot/Doze/OEM survival. Emulators lie. That confidence comes only from the real-device matrix.
- Treating the OS pending set as the store of record. It's a cache. The DB is truth.
Definition of done
-
flutter_local_notificationsimported in exactly one file (check-single-fln-import.shpasses). - Scheduler/recurrence math is pure and Clock-injected (
check-scheduler-purity.shpasses). - No ad-hoc
gateway.schedule/canceloutside the reconcile (check-adhoc-schedule-calls.shpasses). -
tz.localset at startup before anyzonedSchedule. - Recurring schedules stored as wall-clock + rule; one-offs as UTC millis.
-
AndroidScheduleModechosen fromcanScheduleExactAlarms();USE_EXACT_ALARMabsent (check-manifest-permissions.shpasses). - iOS budget ≤ ~50; nearest-ascending window; refill on foreground.
- Deterministic IDs fold in the resolved fire instant; uniqueness tested; reconcile idempotent on unchanged input AND reschedules on an edited fire time (old id cancelled, new id fires at the new time).
- Tap handler
@pragma('vm:entry-point'), no DB write in the isolate, serializable payload → route. - After-restore path:
cancelAll()then full reconcile. - Pure-Dart tests cover recurrence-across-DST, budget boundary, idempotent reconcile, restore-equals-desired.
Related skills
service-boundary-and-native— the injectable throws-until-overridden port provider and MethodChannel quarantine that the gateway follows.naming-conventions— the[Concern]Gatewaysuffix (a thin wrapper over a specific plugin, hereflutter_local_notifications) vs[Concern]Service; owns the role-suffix table this skill follows.persistence-drift— the local DB that is the source of truth; scoped.watchstreams that trigger reconciles.async-safety—BuildContext/mountedguards afterawaitin tap handlers, subscription disposal.state-management-riverpod— providers-as-DI for the gateway and reconcile.i18n-rtl-l10n— localized bodies, ICU plurals, numeral normalization.value-objects-money-and-units— the injectedClockdiscipline shared here.app-startup-and-bootstrap— wheretz.localand channel creation run beforerunApp.testing-strategy— fakes-over-mocks and clock-injected pure-core testing this engine relies on.
References
- flutter_local_notifications: https://pub.dev/packages/flutter_local_notifications
- Zoned scheduling & timezones: https://pub.dev/packages/flutter_local_notifications#scheduling-a-notification
- timezone package: https://pub.dev/packages/timezone
- flutter_timezone: https://pub.dev/packages/flutter_timezone
- Android exact alarms: https://developer.android.com/develop/background-work/services/alarms/schedule
- Android USE_EXACT_ALARM policy: https://support.google.com/googleplay/android-developer/answer/13161072
- iOS UNUserNotificationCenter 64-notification limit: https://developer.apple.com/documentation/usernotifications/unusernotificationcenter
- package:clock: https://pub.dev/packages/clock
What ships with it: 10 files
26.1 KB alongside SKILL.md, 4 of them executable
examples/
references/
scripts/
- check-adhoc-schedule-calls.shruns834 B
- check-manifest-permissions.shruns1.0 KB
- check-scheduler-purity.shruns1.4 KB
- check-single-fln-import.shruns1.1 KB