local-notifications-scheduler について
Flutter アプリ向けのローカル通知スケジューラーを構築するためのスキルです。データベースを唯一の正として、純粋なスケジューリング処理、繰り返し、タイムゾーン、プラットフォーム制限を扱います。
主な機能
- 01データベースを唯一の正とする設計
- 02通知を一元的に再同期
- 03Clock 注入による純粋な処理
- 04夏時間に対応する繰り返し通知
- 05プラットフォーム制限と代替動作
活用シーン
- 01ローカルリマインダーエンジンの設計
- 02繰り返しとスケジューリング処理の端末外テスト
- 03インポートや再起動後の通知復元
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill local-notifications-schedulerインストール後、Claude Code で/local-notifications-schedulerを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/fake_notification_gateway.dartnotification_gateway.dartsync_notifications.dartreferences/notification-gateway-port.mdplatform-boot-exact-alarms-oem.mdscheduler-and-recurrence-rules.mdscripts/check-adhoc-schedule-calls.shcheck-manifest-permissions.shcheck-scheduler-purity.shcheck-single-fln-import.sh詳細
ローカル通知スケジューラー
デバイス上で動作するリマインダーエンジンを一元管理し、APIを絞り込んでいます。ローカルDBが信頼できる唯一の情報源であり、OSの保留中セットは破棄可能なキャッシュです。スケジュールの変更はすべて、純粋な処理を行ってから調整する一連の経路を通るため、エンジン全体をデバイス外で検証できます。ローカル通知のスケジュール、キャンセル、または検討を行う際は、常にこの仕組みを適用してください。
作業内容に応じて、以下のリファレンスを確認してください。
references/notification-gateway-port.md— ポートの契約、2つのアダプター、チャネル/グループ化/ディープリンクのペイロードに関するルール。references/scheduler-and-recurrence-rules.md— 純粋なReminderScheduler.computeの差分、繰り返し/DSTの計算、決定論的ID、予算設定、テストマトリックス全体。references/platform-boot-exact-alarms-oem.md— 起動時の再アーム、正確なアラームの機能低下、バックグラウンド分離での安全性、Androidのマニフェスト/Gradle、OEMの維持状況マトリックス。
PRを作成する前に、scripts/check-single-fln-import.sh、scripts/check-scheduler-purity.sh、scripts/check-adhoc-schedule-calls.sh、scripts/check-manifest-permissions.sh を実行してください。
絶対に守るべきルール
- 唯一の信頼できる情報源はローカル DB です。 OS の保留中の通知セットは照合対象となる破棄可能なキャッシュであり、記録の正本として使ってはいけません。プロセスの終了、再起動、Doze、復元の後でも、すべてのリマインダーを DB だけから再構築できなければなりません。
- スケジュールの変更はすべて、単一の
syncNotifications()照合エントリーポイントを経由させてください。 機能コードや UI コードからgateway.schedule()/cancel()を場当たり的に呼び出してはいけません。この経路では冪等性を確保できず、キャッシュとの不整合が発生します。 flutter_local_notificationsをインポートするファイルは、FLN アダプターの 1 ファイルだけにしてください。 テストも含め、それ以外はすべてNotificationGatewayポートを介してやり取りします。grep ゲートにより、ほかのファイルでのインポートがあるとビルドに失敗します。- スケジュール計算はすべて、純粋で副作用のないクラスにまとめてください。 その中でプラグイン呼び出し、I/O、
DateTime.now()を使ってはいけません。package:clockのClockを注入してください。Riverpod コードではclockProviderから読み取り、純粋な計算にはパラメーターとして渡します(DateTime.now()も独自のClockServiceも使いません)。純粋性があるからこそ、FakeNotificationGatewayを使ったデバイス外でのユニットテストが可能になります。 - 繰り返しスケジュールは壁時計時刻と繰り返しルールとして保存し、スケジュール時にのみ
TZDateTimeに解決してください。 繰り返しスケジュールを UTC の時刻として永続化してはいけません。DST の境界を越えるたびに 1 時間ずれてしまいます。真の単発の時刻は UTC エポックミリ秒で保存します。 - 起動時に
tz.localを設定してください(flutter_timezoneを使用)。timezoneパッケージのデフォルトは UTC です。これを忘れると、すべてのzonedScheduleがローカル時刻の誤った時間に実行されます。 - デフォルトは
AndroidScheduleMode.inexactAllowWhileIdleにしてください(権限不要で、Doze 中も実行できます)。正確な実行は、ユーザーが取り消し可能なSCHEDULE_EXACT_ALARMをcanScheduleExactAlarms()で確認したうえでのみ有効にし、許可されない場合は通知なしで非正確な実行にフォールバックします。USE_EXACT_ALARMは絶対に宣言しないでください。 Play のポリシーではアラーム/タイマー/カレンダーアプリに限定されており、ストアでの審査却下のリスクがあります。 - iOS の保留中通知は約 50 件を上限として見積もってください(非公開の上限 64 件に余裕を持たせます)。将来の実行時刻を昇順に並べ、最も近い約 50 件を選び、アプリがフォアグラウンドになるたびに補充します。65 件目以降はエラーもなく、実行されません。
reminderId + occurrenceIndexに、解決済みの実行時刻と通知内容のハッシュを加えて、決定論的な ID を使用してください。getPending()で取得できるのはidだけで、whenは含まれません。そのため、ID に実行時刻を含めないと、出現回数が変わらないリマインダーを編集した場合(単発の時刻を 09:00 から 14:00 に変更する場合や、毎日のルールの時刻を変更する場合)、同じ ID が使われ、キャンセルとスケジュールの両方のループで処理がスキップされ、古い時刻に実行されてしまいます。解決済みのTZDateTime(および通知内容)を ID に組み込むと、編集時に新しい ID が生成されます。古い ID はキャンセルされ、新しい ID がスケジュールされます。入力に変更がなければ ID も変わらないため、照合は何もしません。- アプリがフォアグラウンドになるたびの照合が、信頼性の基盤です。 起動レシーバー、バックグラウンドでの定期実行、正確なアラームの切り替え、OEM による維持は、すべて明示的にベストエフォートです。サーバープッシュがなければ、通知が失われたことを知る方法は、次回フォアグラウンドになった際の照合差分しかありません。この制約は、問題を取り繕うべき欠陥ではなく、まさにこのアーキテクチャを採用する理由です。
パッケージ/フォルダー構成
このエンジンは機能ではなく、共有インフラストラクチャです。純粋な計算処理は core/(Flutter に依存せず、デバイス外でテスト可能)に置き、副作用を扱うポート、実稼働実装、照合処理は services/ に置きます。このツリーは project-structure-and-packages が管理します。
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
マルチパッケージ(ワークスペース)の場合: 機能からの入力を受け取る約 N 個の機能間で実装が分岐しないよう、純粋な計算処理とゲートウェイを
packages/notificationsパッケージに移します(lib/src/と公開用の単一バレルが認められている規約です)。単一パッケージのアプリなら、core/とservices/の分割で十分です。project-structure-and-packagesを参照してください。
Riverpod プロバイダーを通じてインスタンスを構築し、グローバルシングルトンや機能ごとのインスタンスは使用しないでください。Clock は clockProvider から取得します(テストでは Clock.fixed(...) を使用します)。ポートプロバイダーをオーバーライドするまで例外を投げるパターンについては、service-boundary-and-native を参照してください。
正規の reconcile(中核)
compute は純粋であり、IO を行うのはゲートウェイの差分処理だけです。スケジュール変更がすべて通る唯一のエントリーポイントです。
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);
}
}
アプリのフォアグラウンド復帰/再開、リマインダーの CRUD、バックアップのインポート/復元、正確なアラームの権限付与、Android の起動、毎日のバックグラウンド処理の際に syncNotifications() を呼び出します。復元後の処理を含む注釈付きのバージョンは examples/sync_notifications.dart を参照してください。
ゲートウェイポート
このポートによってプラグインを分離し、スケジュール計算とすべてのテストをデバイス外で実行できます。
abstract class NotificationGateway {
Future<void> schedule(ScheduledNotification n);
Future<void> cancel(int id);
Future<void> cancelAll();
Future<List<PendingNotification>> getPending();
}
操作はこの 4 つだけにしてください。それ以上必要な場合は、そのロジックを純粋な ReminderScheduler に置くべきです。具体的な FlnNotificationGateway は、flutter_local_notifications、timezone、flutter_timezone をインポートする唯一のファイルです。FakeNotificationGateway はメモリー内の List です。具体的なゲートウェイは Riverpod プロバイダー経由で接続し、テストでは ProviderContainer 内でフェイクに差し替えます。契約の全容と ScheduledNotification 値オブジェクトについては、references/notification-gateway-port.md と examples/notification_gateway.dart を参照してください。
繰り返しと DST
壁時計の時刻とルールを、スケジュール時に tz.local の TZDateTime に解決します。これにより、設計上 DST に正しく対応できます。
// 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;
}
本当にカレンダーに基づいて繰り返す項目には、単一のスロットを消費する繰り返し通知(matchDateTimeComponents)を 1 つ使用できます。それ以外はすべて、reconcile のたびに再計算する単発通知にします。完了時の再アンカー設定: 繰り返しリマインダーがずれないよう、次回の発生時刻は予定時刻ではなく、実際の完了時刻を基準に計算します。「午前 9 時」を UTC の時刻として保存してはいけません。
アプリによっては、時刻以外のトリガー(例:「N 回完了した後にリマインド」)からリマインダーの時刻を導き出します。この投影処理は、Clock を注入した純粋なヘルパーに保持し、スケジューラーに渡す前に具体的な未来の時刻へ解決して、スケジューラーが同質な時刻のリストを扱えるようにします。入力ストリームによって再スケジュールが頻発しないよう、投影処理をデバウンスします。
バックグラウンド分離 Isolate とタップ処理
バックグラウンドのタップ/アクションハンドラーには @pragma('vm:entry-point') が必要です。また、メイン Isolate とは状態を共有しない別の Isolate で実行されます。そこから DB に書き込んではいけません。軽量な「保留中のアクション」インテントを記録する(または何もしない)にとどめ、2 つの Isolate から同時に DB にアクセスするのを避けて、次回フォアグラウンドになった際の reconcile に実際の処理を任せます。タップ時は、シリアライズ可能なペイロードから、DB を使って再構築した go_router のロケーションへ対応付けます。シリアライズできない extra は使用しません。await 後の mounted/コンテキストのガードについては async-safety を、references/platform-boot-exact-alarms-oem.md も参照してください。
ローカライズとバックアップ
通知のタイトルと本文は、ICU の複数形と数値の正規化を用いて gen-l10n でローカライズします。表示カレンダーにかかわらず、スケジュール時刻はグレゴリオ暦の TZDateTime のままです(i18n-rtl-l10n を参照)。バックアップには OS の通知状態や正確なアラームの許可状態は含まれません。インポート後は cancelAll() を実行してから完全な reconcile を行ってください。復元された保留中の ID が有効だと決めつけてはいけません。
アンチパターン
- 機能/ViewModel から
gateway.schedule()を呼び出すこと。 キャッシュの同期がずれ、冪等にできません。syncNotifications()経由で処理してください。 - 繰り返しスケジュールを UTC の瞬間として永続化すること。 DST の切り替わりのたびに、通知時刻が 1 時間ずれます。壁時計の時刻とルールを保存してください。
- スケジューラー内で
DateTime.now()を使うこと。 デバイス外でのテストができなくなります。Clockを注入してください。 - 「時間どおりに鳴らす」ために
USE_EXACT_ALARMを宣言すること。 Play ポリシーに違反し、却下されるリスクがあります。SCHEDULE_EXACT_ALARMのオプトインを使い、利用できない場合は不正確なアラームにフォールバックしてください。 - 上限のないセットをスケジュールすること。 iOS は過去の通知を含めて 64 件を超えると、通知を黙って破棄します。上限を設けて補充してください。
- タップ処理の分離 isolate から DB に書き込むこと。 同時アクセスにより破損するリスクがあります。意図を記録し、フォアグラウンド復帰時に整合してください。
- 再起動/Doze/OEM 環境での維持について、エミュレーターでテストが成功しただけで信頼すること。 エミュレーターの結果は当てになりません。確信を得るには、実機のテストマトリクスが必要です。
- OS の保留中セットを正式なデータ保存先として扱うこと。 それはキャッシュです。正となるデータは DB にあります。
完了の定義
-
flutter_local_notificationsが 1 つのファイルだけでインポートされている(check-single-fln-import.shが成功する)。 - スケジューラー/繰り返し計算が純粋で、
Clockが注入されている(check-scheduler-purity.shが成功する)。 - reconcile の外部に場当たり的な
gateway.schedule/cancel呼び出しがない(check-adhoc-schedule-calls.shが成功する)。 -
zonedScheduleの実行前に、起動時にtz.localが設定されている。 - 繰り返しスケジュールは壁時計の時刻とルールとして、単発スケジュールは UTC ミリ秒として保存されている。
-
AndroidScheduleModeがcanScheduleExactAlarms()に基づいて選択されており、USE_EXACT_ALARMが存在しない(check-manifest-permissions.shが成功する)。 - iOS の上限が約 50 件以下で、最も近い時刻から昇順に並ぶウィンドウになっており、フォアグラウンド復帰時に補充される。
- 決定的な ID に解決済みの発火時刻が含まれ、一意性がテストされている。また、入力が変更されていない場合は reconcile が冪等であり、発火時刻が編集された場合は再スケジュールされる(古い ID はキャンセルされ、新しい ID は新しい時刻に発火する)。
- タップハンドラーに
@pragma('vm:entry-point')が付いており、分離 isolate 内で DB に書き込まず、シリアライズ可能なペイロードからルートへ遷移する。 - 復元後の処理で
cancelAll()を実行してから、完全な reconcile を行う。 - 純粋な Dart テストで、DST をまたぐ繰り返し、上限境界、冪等な reconcile、復元後の状態が期待値と一致することをカバーしている。
関連スキル
service-boundary-and-native— このゲートウェイが従う、オーバーライドされるまで例外を投げる注入可能なポートプロバイダーと MethodChannel の隔離。naming-conventions—[Concern]Gatewayという接尾辞(ここではflutter_local_notificationsなど、特定のプラグインを薄くラップするもの)と[Concern]Service。このスキルが従う役割別接尾辞の一覧を管理します。persistence-drift— 正となるデータソースであるローカル DB と、reconcile をトリガーするスコープ付き.watchストリーム。async-safety— タップハンドラー内でawaitの後に行うBuildContext/mountedのガードと、サブスクリプションの破棄。state-management-riverpod— ゲートウェイと reconcile に対する、DI としてのプロバイダー。i18n-rtl-l10n— ローカライズされた本文、ICU の複数形、数字の正規化。value-objects-money-and-units— ここでも共有する、注入されたClockの規律。app-startup-and-bootstrap—runAppの前にtz.localの設定とチャネルの作成を行う場所。testing-strategy— このエンジンが依存する、モックよりフェイクを使う方針と、Clock を注入した純粋なコアのテスト。
参考資料
- flutter_local_notifications:https://pub.dev/packages/flutter_local_notifications
- ゾーンスケジュールとタイムゾーン:https://pub.dev/packages/flutter_local_notifications#scheduling-a-notification
- timezone パッケージ:https://pub.dev/packages/timezone
- flutter_timezone:https://pub.dev/packages/flutter_timezone
- Android の正確なアラーム:https://developer.android.com/develop/background-work/services/alarms/schedule
- Android の USE_EXACT_ALARM ポリシー:https://support.google.com/googleplay/android-developer/answer/13161072
- iOS の UNUserNotificationCenter における通知数の上限(64 件):https://developer.apple.com/documentation/usernotifications/unusernotificationcenter
- package:clock:https://pub.dev/packages/clock