Files
calendula/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
Jean-Luc Makiola b03bd67678 feat(reminders): reminder notifications — EVENT_REMINDER receiver, onboarding step, settings toggle (v1.4)
Calendula now posts event reminders itself (the Etar model): the provider
schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must
turn them into visible notifications — essential for users whose only
calendar app this is. A manifest-registered, exported receiver (data scheme
content://com.android.calendar) wakes us at reminder time; no foreground
service, no own alarm scheduling.

Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) →
ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with
ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a
dedicated high-importance channel, then best-effort marks rows FIRED
(needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag
per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED
rows are never re-queried, so no dismiss-intent machinery. Research
(AOSP CalendarAlarmManager): the provider creates alert rows only for
METHOD_ALERT reminders, so the email-reminder filter happens upstream.

Tapping opens the event's detail screen: MainActivity is singleTop now,
parses eventId/begin/end extras (onCreate + onNewIntent) into Compose
state, and CalendarHost consumes the key exactly like an event tap.

Onboarding gained a one-time second step after the calendar grant (shared
OnboardingScaffold extracted from PermissionScreen): explains delivery,
warns that a second calendar app with notifications on duplicates
reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29).
"Not now" turns the feature off; reminders default ON. Settings mirrors
the toggle in a new Notifications section with the duplicate hint, and
re-requests the permission when enabling. Strings DE+EN.

Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED /
exact-alarm scheduling, battery-exemption prompts.

Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day,
midnight-crossing ranges), reminders/onboarding pref round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:23:34 +02:00

6.4 KiB
Raw Blame History

Calendula - Plan 04: Reminder Notifications (v1.4)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell). Der Provider plant die Alarme und broadcastet android.intent.action.EVENT_REMINDER — die sichtbare Notification postet er nicht, das muss eine Kalender-App tun. Für Nutzer, deren einzige Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have. ./gradlew lint test assembleDebug bleibt grün; Release erst nach On-Device-Review.

Architecture: Eigenes kleines Datenmodul data/reminders/ neben data/calendar/ — der Receiver braucht weder Repository noch Flows. Schichtung wie gehabt: EventReminderReceiver (Hilt-EntryPoint) → ReminderAlertStore (Interface, Android-Impl auf CalendarAlerts) → ReminderNotifier (NotificationManager). Domain bleibt pure Kotlin (ReminderAlert-Modell, JVM-testbare Textformatierung).

Recherche-Befunde (AOSP CalendarAlarmManager + Etar, 2026-06-11):

  1. Der Provider legt CalendarAlerts-Rows nur für METHOD_ALERT-Reminder an (AOSP-Query: AND method=1). Der im Roadmap-Eintrag geforderte METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als METHOD_ALERT.
  2. Der Broadcast ist implizit (Action + content://com.android.calendar/…-URI, Extra alarmTime). Etars Manifest-Receiver ist exported="true" mit <data android:scheme="content"/> — das übernehmen wir (plus Host, enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst "fällig & noch SCHEDULED".
  3. Etar postet aus dem Zustand SCHEDULED FIRED und verwaltet Dismiss über eigene Services. Wir vereinfachen: nur STATE_SCHEDULED AND alarmTime <= now posten, danach best-effort auf FIRED setzen (braucht WRITE_CALENDAR; SecurityException wird geschluckt). Weggewischte Notifications kommen so nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an. Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert + setOnlyAlertOnce).

Leitentscheidungen:

  1. Kein eigenes Alarm-Scheduling (kein SCHEDULE_EXACT_ALARM, kein BOOT_COMPLETED, kein WorkManager): Zustellung hängt am Provider-Broadcast. Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
  2. Toggle default ON, Onboarding-Schritt danach: Nach dem Kalender-Grant folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission- Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit aktiven Notifications), fragt POST_NOTIFICATIONS an (nur API 33+ zeigt einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt erscheint genau einmal (reminder_onboarding_done-Pref) — auch für v1.0v1.3-Upgrader, die das Feature so entdecken.
  3. Settings-Spiegel: Abschnitt "Erinnerungen" mit demselben Toggle + Duplikat-Hinweis. Einschalten fordert POST_NOTIFICATIONS kontextuell an, wenn sie fehlt.
  4. Tap öffnet das Event-Detail: Notification-Intent trägt eventId/begin/end; MainActivity wird singleTop, reicht den Key als Compose-State an CalendarHost durch (gleiches LongArray-Key-Muster wie der Detail-Overlay selbst).
  5. Ein Kanal, einfache Inhalte: Kanal "Erinnerungen" (IMPORTANCE_HIGH), pro Alert eine Notification (Tag = Alert-Id): Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne (ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein Vollbild-Alarm.

Tasks

Manifest / Resourcen:

  • POST_NOTIFICATIONS ins Manifest; Receiver .reminders.EventReminderReceiver exported="true", Intent-Filter EVENT_REMINDER + data scheme=content host=com.android.calendar; MainActivitylaunchMode="singleTop"
  • Monochromes Notification-Icon drawable/ic_notification.xml
  • Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis

Prefs:

  • SettingsPrefs.remindersEnabled (default true) + Setter; reminderOnboardingDone (default false) + Setter; SettingsPrefsTest

Data layer (data/reminders/):

  • ReminderAlert-Modell (in data/reminders/, nicht domain — Alerts erreichen nie einen Screen): alertId, eventId, begin/end als Millis, title, location, isAllDay
  • ReminderAlertStore (Interface) + AndroidReminderAlertStore: dueAlerts(nowMillis) = CalendarAlerts mit STATE_SCHEDULED AND ALARM_TIME <= now; markFired(ids, nowMillis) setzt STATE/RECEIVED_TIME/NOTIFY_TIME, SecurityException → Log (Write-Recht optional)
  • ReminderNotifier: Kanal lazy anlegen, eine Notification pro Alert (Tag = alertId, setOnlyAlertOnce, autoCancel, when = begin, Category EVENT), Content-PendingIntent auf MainActivity mit eventId/begin/end
  • Zeitspannen-Text als pure Funktion (JVM-testbar) + Test

Receiver:

  • EventReminderReceiver (@AndroidEntryPoint): Action prüfen, goAsync(); raus, wenn Pref aus, READ_CALENDAR fehlt oder Notifications systemseitig geblockt; sonst posten → markFired

UI:

  • Onboarding-Shell aus PermissionScreen extrahieren (OnboardingScaffold + BenefitRow, intern wiederverwendet)
  • NotificationOnboardingScreen + ViewModel: Benefit-Rows (verpasst nichts / Duplikat-Warnung), Primär-Button fordert POST_NOTIFICATIONS (API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide setzen reminder_onboarding_done
  • RootScreen: Kalender-Gate → Reminder-Schritt (einmalig) → CalendarHost
  • CalendarHost: externer Detail-Key (Notification-Tap) wird wie ein Event-Tap konsumiert; MainActivity parst Intent (onCreate + onNewIntent) in Compose-State
  • Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem Permission-Request beim Einschalten) + Duplikat-Hinweistext

Abschluss:

  • ./gradlew lint test assembleDebug grün
  • CHANGELOG ([Unreleased]), ROADMAP-Status; kein Tag/Release vor On-Device-Review