Local Notifications Scheduler logo

Local Notifications Scheduler

Community
zakariaf
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.

Overview

Publisherzakariaf
RepositoryFlutter-Skills
Skill namelocal-notifications-scheduler
Stars
64
Forks
15
Bundled files
10
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 10 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by zakariaf on GitHub. Read the source before you install it.

Installation

Install the Local Notifications Scheduler AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/zakariaf/Flutter-Skills/tree/main/skills/local-notifications-scheduler
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/zakariaf/Flutter-Skills.git /tmp/Flutter-Skills
mkdir -p .claude/skills
cp -r /tmp/Flutter-Skills/skills/local-notifications-scheduler .claude/skills/local-notifications-scheduler
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Local Notifications Scheduler in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Local Notifications Scheduler on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Local Notifications Scheduler is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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 — pure ReminderScheduler.compute diff, 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

  1. 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.
  2. Route EVERY scheduling change through one syncNotifications() reconcile entrypoint. Never call gateway.schedule()/cancel() ad hoc from feature or UI code — that path cannot be made idempotent and desyncs the cache.
  3. Import flutter_local_notifications in exactly one file — the FLN adapter. Everything else, including all tests, talks to the NotificationGateway port. A grep gate fails the build on any other import.
  4. Keep all scheduling math in pure, side-effect-free classes. No plugin calls, no IO, no DateTime.now() inside them — inject a Clock from package:clock: Riverpod code reads it from clockProvider, the pure math takes it as a parameter (never DateTime.now(), never a bespoke ClockService). Purity is what makes off-device unit testing via FakeNotificationGateway possible.
  5. Store recurring schedules as wall-clock + recurrence rule, resolve to TZDateTime only 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.
  6. Set tz.local at startup (from flutter_timezone). The timezone package defaults to UTC — forget this and every zonedSchedule fires at the wrong local hour.
  7. Default to AndroidScheduleMode.inexactAllowWhileIdle (permission-free, pierces Doze). Gate exact firing behind the user-revocable SCHEDULE_EXACT_ALARM via canScheduleExactAlarms() with silent fallback to inexact. Never declare USE_EXACT_ALARM — Play policy restricts it to alarm/timer/calendar apps and risks store rejection.
  8. 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.
  9. Use deterministic IDs derived from reminderId + occurrenceIndex plus a hash of the resolved fire instant and notification content. getPending() exposes only the id, not when — 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 resolved TZDateTime (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.
  10. 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.

text
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/notifications package (where lib/src/ + one public barrel is the sanctioned convention) so the ~N features that feed it cannot diverge. In a single-package app the core/ + services/ split is enough. See project-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.

dart
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.

dart
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.

dart
// 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 through syncNotifications().
  • 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 a Clock.
  • Declaring USE_EXACT_ALARM to "just make it fire on time." Play-policy rejection risk. Use the SCHEDULE_EXACT_ALARM opt-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_notifications imported in exactly one file (check-single-fln-import.sh passes).
  • Scheduler/recurrence math is pure and Clock-injected (check-scheduler-purity.sh passes).
  • No ad-hoc gateway.schedule/cancel outside the reconcile (check-adhoc-schedule-calls.sh passes).
  • tz.local set at startup before any zonedSchedule.
  • Recurring schedules stored as wall-clock + rule; one-offs as UTC millis.
  • AndroidScheduleMode chosen from canScheduleExactAlarms(); USE_EXACT_ALARM absent (check-manifest-permissions.sh passes).
  • 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]Gateway suffix (a thin wrapper over a specific plugin, here flutter_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 .watch streams that trigger reconciles.
  • async-safety — BuildContext/mounted guards after await in 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 injected Clock discipline shared here.
  • app-startup-and-bootstrap — where tz.local and channel creation run before runApp.
  • testing-strategy — fakes-over-mocks and clock-injected pure-core testing this engine relies on.

References

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Local Notifications Scheduler AI skill do?

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-poi...

Why use Local Notifications Scheduler on TypingMind?

Because you install it once and use it with any model. Local Notifications Scheduler is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Local Notifications Scheduler in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/zakariaf/Flutter-Skills/tree/main/skills/local-notifications-scheduler. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Local Notifications Scheduler?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Local Notifications Scheduler?

As many as you like. As long as a model supports skills, you can use Local Notifications Scheduler with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Local Notifications Scheduler AI skill free?

Yes. It is published on GitHub by zakariaf under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇