diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 70bab21..7efcd17 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -65,7 +65,7 @@ guide here, not a contract — scope per slice is decided as we go.
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, scoped recurring writes (this / following / all), recurrence picker | complete (shipped 2026-06-11) |
-| v1.4 | Reminder notifications — see below | planned |
+| v1.4 | Reminder notifications — see below | implemented (release awaits on-device review) |
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
## v1.4 — Reminder Notifications
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15266b4..28bada9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Reminder notifications (v1.4): Calendula now delivers event reminders as
+ notifications itself — the system schedules them but posts nothing, so a
+ calendar app must (essential when Calendula is the only one installed).
+ Due reminders appear on a dedicated "Event reminders" channel; tapping one
+ opens the event's detail screen. Email reminders are never posted (the
+ provider only schedules alert-type reminders)
+- A one-time onboarding step after the calendar grant introduces reminders,
+ requests the notification permission (Android 13+), and warns that a second
+ calendar app with notifications on will duplicate them. "Not now" leaves
+ the feature off
+- Settings gained a "Notifications" section mirroring the choice: an event-
+ reminders toggle (default on) with the duplicate-reminders hint; turning it
+ on re-requests the notification permission when missing
+
## [1.3.0] — 2026-06-11
### Added
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f8e8b0..de39da0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
@@ -26,6 +28,20 @@
+
+
+
+
+
+
+
+
(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ requestedDetailKey = intent.detailKeyOrNull()
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
// and the Settings screen, so a theme change applies app-wide at once.
@@ -35,8 +47,51 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
- RootScreen(modifier = Modifier.fillMaxSize())
+ RootScreen(
+ modifier = Modifier.fillMaxSize(),
+ requestedDetailKey = requestedDetailKey,
+ onDetailKeyConsumed = { requestedDetailKey = null },
+ )
}
}
}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ intent.detailKeyOrNull()?.let { requestedDetailKey = it }
+ }
+
+ private fun Intent.detailKeyOrNull(): LongArray? {
+ val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
+ if (eventId == -1L) return null
+ return longArrayOf(
+ eventId,
+ getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
+ getLongExtra(EXTRA_END_MILLIS, 0L),
+ )
+ }
+
+ companion object {
+ private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
+ private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
+ private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
+
+ /**
+ * Intent opening the detail screen of one occurrence (reminder
+ * notifications). The synthetic data URI keys the intent so
+ * PendingIntents for different occurrences never collapse into one.
+ */
+ fun eventDetailIntent(
+ context: Context,
+ eventId: Long,
+ beginMillis: Long,
+ endMillis: Long,
+ ): Intent = Intent(context, MainActivity::class.java).apply {
+ data = "calendula://event/$eventId/$beginMillis".toUri()
+ putExtra(EXTRA_EVENT_ID, eventId)
+ putExtra(EXTRA_BEGIN_MILLIS, beginMillis)
+ putExtra(EXTRA_END_MILLIS, endMillis)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt
index 09d37a1..b0ebcde 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
+import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
+import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -37,6 +39,12 @@ abstract class DataBindModule {
abstract fun bindCalendarRepository(
impl: CalendarRepositoryImpl,
): CalendarRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindReminderAlertStore(
+ impl: AndroidReminderAlertStore,
+ ): ReminderAlertStore
}
@Module
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
index c938d27..a96e778 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
@@ -86,6 +86,31 @@ class SettingsPrefs @Inject constructor(
}
}
+ /**
+ * Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
+ * for users whose only calendar app this is, reminders are essential; the
+ * onboarding step and Settings warn about duplicates from a second app.
+ */
+ val remindersEnabled: Flow = store.data.map { prefs ->
+ prefs[REMINDERS_ENABLED_KEY] ?: true
+ }
+
+ suspend fun setRemindersEnabled(enabled: Boolean) {
+ store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
+ }
+
+ /**
+ * Whether the one-time reminder onboarding step (after the calendar
+ * grant) has been shown — also true for users who tapped "not now".
+ */
+ val reminderOnboardingDone: Flow = store.data.map { prefs ->
+ prefs[REMINDER_ONBOARDING_KEY] ?: false
+ }
+
+ suspend fun setReminderOnboardingDone() {
+ store.edit { it[REMINDER_ONBOARDING_KEY] = true }
+ }
+
private fun parseFormFields(stored: String?): Set = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -98,6 +123,8 @@ class SettingsPrefs @Inject constructor(
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
+ internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
+ internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt
new file mode 100644
index 0000000..a5ed60c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt
@@ -0,0 +1,57 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import android.Manifest
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.provider.CalendarContract
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Becomes the app that turns the calendar provider's reminder alarms into
+ * visible notifications (the Etar model — the provider broadcasts
+ * `EVENT_REMINDER` at reminder time but posts nothing itself).
+ *
+ * The broadcast's data URI only carries the alarm time, so it is ignored:
+ * we query every still-scheduled, due `CalendarAlerts` row ourselves, post
+ * them, and mark them fired. Posting happens before marking — a crash in
+ * between re-posts silently (same tag) rather than losing the reminder.
+ */
+@AndroidEntryPoint
+class EventReminderReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var alertStore: ReminderAlertStore
+ @Inject lateinit var notifier: ReminderNotifier
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
+ val readGranted = ContextCompat.checkSelfPermission(
+ context, Manifest.permission.READ_CALENDAR,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (!readGranted || !notifier.canPost()) return
+
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ if (settingsPrefs.remindersEnabled.first()) {
+ val now = System.currentTimeMillis()
+ val due = alertStore.dueAlerts(now)
+ due.forEach(notifier::post)
+ alertStore.markFired(due.map { it.alertId }, now)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt
new file mode 100644
index 0000000..5d14579
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertStore.kt
@@ -0,0 +1,112 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import android.content.ContentValues
+import android.content.Context
+import android.provider.CalendarContract
+import android.util.Log
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * One due row of the provider's `CalendarAlerts` table (a join with Events).
+ * Stays in the data layer: alerts feed the notification path only and never
+ * reach a screen, so there is no domain model for them.
+ */
+data class ReminderAlert(
+ val alertId: Long,
+ val eventId: Long,
+ val beginMillis: Long,
+ val endMillis: Long,
+ /** Raw event title; may be blank — the notifier substitutes "(no title)". */
+ val title: String,
+ val location: String?,
+ val isAllDay: Boolean,
+)
+
+/**
+ * Seam over the `CalendarAlerts` table so the receiver logic can be exercised
+ * without a ContentResolver. The provider creates these rows itself — only
+ * for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
+ * email reminders never show up here.
+ */
+interface ReminderAlertStore {
+
+ /** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
+ fun dueAlerts(nowMillis: Long): List
+
+ /**
+ * Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
+ * not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
+ * which the user may have declined — then re-broadcasts silently replace
+ * the already-posted notifications instead (same tag, alert-once).
+ */
+ fun markFired(alertIds: List, nowMillis: Long)
+}
+
+@Singleton
+class AndroidReminderAlertStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : ReminderAlertStore {
+
+ override fun dueAlerts(nowMillis: Long): List = context.contentResolver.query(
+ CalendarContract.CalendarAlerts.CONTENT_URI,
+ PROJECTION,
+ CalendarContract.CalendarAlerts.STATE + " = ? AND " +
+ CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
+ arrayOf(
+ CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
+ nowMillis.toString(),
+ ),
+ CalendarContract.CalendarAlerts.BEGIN + " ASC",
+ )?.use { c ->
+ buildList {
+ while (c.moveToNext()) {
+ add(
+ ReminderAlert(
+ alertId = c.getLong(0),
+ eventId = c.getLong(1),
+ beginMillis = c.getLong(2),
+ endMillis = c.getLong(3),
+ title = c.getString(4).orEmpty(),
+ location = c.getString(5)?.takeIf { it.isNotBlank() },
+ isAllDay = c.getInt(6) == 1,
+ ),
+ )
+ }
+ }
+ } ?: emptyList()
+
+ override fun markFired(alertIds: List, nowMillis: Long) {
+ if (alertIds.isEmpty()) return
+ val values = ContentValues().apply {
+ put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
+ put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
+ put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
+ }
+ try {
+ context.contentResolver.update(
+ CalendarContract.CalendarAlerts.CONTENT_URI,
+ values,
+ CalendarContract.CalendarAlerts._ID +
+ " IN (" + alertIds.joinToString(",") + ")",
+ null,
+ )
+ } catch (e: SecurityException) {
+ Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
+ }
+ }
+
+ private companion object {
+ const val TAG = "ReminderAlertStore"
+ val PROJECTION = arrayOf(
+ CalendarContract.CalendarAlerts._ID,
+ CalendarContract.CalendarAlerts.EVENT_ID,
+ CalendarContract.CalendarAlerts.BEGIN,
+ CalendarContract.CalendarAlerts.END,
+ CalendarContract.CalendarAlerts.TITLE,
+ CalendarContract.CalendarAlerts.EVENT_LOCATION,
+ CalendarContract.CalendarAlerts.ALL_DAY,
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
new file mode 100644
index 0000000..5c60011
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
@@ -0,0 +1,101 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.calendula.MainActivity
+import de.jeanlucmakiola.calendula.R
+import java.time.ZoneId
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Posts one notification per due reminder alert on a dedicated channel.
+ * Tapping opens the event's detail screen; the tag is the alert id, so a
+ * re-broadcast of an alert we couldn't mark fired replaces its notification
+ * silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
+ * duplicating it.
+ */
+@Singleton
+class ReminderNotifier @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
+ fun canPost(): Boolean {
+ val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+
+ fun post(alert: ReminderAlert) {
+ ensureChannel()
+ val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
+ val time = reminderTimeText(
+ beginMillis = alert.beginMillis,
+ endMillis = alert.endMillis,
+ isAllDay = alert.isAllDay,
+ zone = ZoneId.systemDefault(),
+ locale = Locale.getDefault(),
+ )
+ val text = listOfNotNull(time, alert.location).joinToString(" · ")
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(text)
+ .setWhen(alert.beginMillis)
+ .setShowWhen(false)
+ .setCategory(NotificationCompat.CATEGORY_EVENT)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(detailIntent(alert))
+ .build()
+ try {
+ NotificationManagerCompat.from(context)
+ .notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
+ } catch (e: SecurityException) {
+ // POST_NOTIFICATIONS was revoked between canPost() and here.
+ Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
+ }
+ }
+
+ private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
+ context,
+ /* requestCode = */ alert.alertId.toInt(),
+ MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ /** Channel creation is idempotent; re-running refreshes the localized name. */
+ private fun ensureChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.reminder_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = context.getString(R.string.reminder_channel_description)
+ }
+ NotificationManagerCompat.from(context).createNotificationChannel(channel)
+ }
+
+ private companion object {
+ const val TAG = "ReminderNotifier"
+ const val CHANNEL_ID = "reminders"
+
+ // One id, distinct tags: the tag (alert id) already keys the
+ // notification, so a fixed id keeps cancellation/replacement simple.
+ const val NOTIFICATION_ID = 1
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt
new file mode 100644
index 0000000..70b1354
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt
@@ -0,0 +1,56 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+
+/**
+ * The one line of time context in a reminder notification. Pure so it can be
+ * JVM-tested:
+ *
+ * - timed, same day: "09:30 – 10:00"
+ * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
+ * - all-day, one day: "11 Jun 2026"
+ * - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
+ *
+ * All-day instances store UTC midnights with an exclusive end, so they are
+ * read in UTC and the end day is the last *covered* day.
+ */
+fun reminderTimeText(
+ beginMillis: Long,
+ endMillis: Long,
+ isAllDay: Boolean,
+ zone: ZoneId,
+ locale: Locale,
+): String {
+ if (isAllDay) {
+ val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
+ // (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+)
+ val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
+ val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
+ .minusDays(1)
+ .coerceAtLeast(firstDay)
+ return if (lastDay == firstDay) {
+ dateFormat.format(firstDay)
+ } else {
+ dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
+ }
+ }
+
+ val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
+ val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
+ val end = Instant.ofEpochMilli(endMillis).atZone(zone)
+ return if (begin.toLocalDate() == end.toLocalDate()) {
+ timeFormat.format(begin) + RANGE + timeFormat.format(end)
+ } else {
+ val dateTimeFormat = DateTimeFormatter
+ .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
+ .withLocale(locale)
+ dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
+ }
+}
+
+private const val RANGE = " – "
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 8d95d2c..c511cdb 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,9 +30,18 @@ import kotlinx.datetime.LocalDate
* Holds the active top-level view (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
+ *
+ * [requestedDetailKey] is an externally requested occurrence (a tapped
+ * reminder notification routed through MainActivity): it opens the detail
+ * overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
+ * so a later recomposition can't re-open it.
*/
@Composable
-fun CalendarHost(modifier: Modifier = Modifier) {
+fun CalendarHost(
+ modifier: Modifier = Modifier,
+ requestedDetailKey: LongArray? = null,
+ onDetailKeyConsumed: () -> Unit = {},
+) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
@@ -61,6 +71,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
detailKey = key
}
+ // A tapped reminder notification asks for a specific occurrence.
+ LaunchedEffect(requestedDetailKey) {
+ if (requestedDetailKey != null) {
+ heldKey = requestedDetailKey
+ detailKey = requestedDetailKey
+ onDetailKeyConsumed()
+ }
+ }
+
// Settings (M4) is hoisted here so it overlays whichever calendar view is
// active and survives view switches. (The calendar filter now lives inline
// in the navigation drawer, so no overlay state is needed for it.)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
index d0a07e9..72fe38e 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt
@@ -10,14 +10,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
+import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
+import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@Composable
-fun RootScreen(modifier: Modifier = Modifier) {
+fun RootScreen(
+ modifier: Modifier = Modifier,
+ requestedDetailKey: LongArray? = null,
+ onDetailKeyConsumed: () -> Unit = {},
+) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
@@ -40,7 +48,23 @@ fun RootScreen(modifier: Modifier = Modifier) {
}
if (hasPermission) {
- CalendarHost(modifier = modifier)
+ // Second onboarding gate (v1.4, one-time): reminder notifications.
+ // Null until DataStore's first emission — render nothing for that
+ // frame instead of flashing the wrong screen.
+ val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
+ val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
+ when (onboardingDone) {
+ true -> CalendarHost(
+ modifier = modifier,
+ requestedDetailKey = requestedDetailKey,
+ onDetailKeyConsumed = onDetailKeyConsumed,
+ )
+ false -> ReminderOnboardingScreen(
+ onFinished = reminderOnboarding::finish,
+ modifier = modifier,
+ )
+ null -> {}
+ }
} else {
PermissionScreen(
onGranted = { hasPermission = true },
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/OnboardingScaffold.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/OnboardingScaffold.kt
new file mode 100644
index 0000000..fbc5f58
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/OnboardingScaffold.kt
@@ -0,0 +1,163 @@
+package de.jeanlucmakiola.calendula.ui.permission
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.calendula.R
+
+/** MD3 8dp spacing scale shared by the onboarding screens. */
+internal object OnboardingSpace {
+ val xs = 8.dp
+ val sm = 16.dp
+ val md = 24.dp
+ val lg = 32.dp
+ val xl = 48.dp
+}
+
+/**
+ * Shared onboarding shell (calendar grant, reminder step): a scrollable,
+ * centred hero + body with the call(s) to action pinned to the bottom (clear
+ * of the navigation bar). The content slot is centred horizontally; benefit
+ * rows fill the width so their own content left-aligns.
+ */
+@Composable
+internal fun OnboardingScaffold(
+ hero: @Composable () -> Unit,
+ actions: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ body: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ content = actions,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = OnboardingSpace.md),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.height(OnboardingSpace.xl))
+ hero()
+ Spacer(Modifier.height(OnboardingSpace.lg))
+ body()
+ Spacer(Modifier.height(OnboardingSpace.md))
+ }
+ }
+}
+
+/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
+@Composable
+internal fun BrandHero(denied: Boolean) {
+ Box(contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .clip(RoundedCornerShape(34.dp))
+ .background(colorResource(R.color.ic_launcher_background)),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ if (denied) {
+ // A small lock badge sits over the corner to signal "blocked".
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .offset(x = 10.dp, y = 10.dp)
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.errorContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Lock,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
+}
+
+/** One trust point: a tonal icon chip on the left, title + supporting text right. */
+@Composable
+internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(Modifier.width(OnboardingSpace.sm))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
index 7f25b2f..c7f3f9d 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
@@ -6,25 +6,14 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
@@ -34,7 +23,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -42,18 +30,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
private val CALENDAR_PERMISSIONS = arrayOf(
@@ -61,15 +44,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.WRITE_CALENDAR,
)
-// MD3 8dp spacing scale, scoped to this screen.
-private object Space {
- val xs = 8.dp
- val sm = 16.dp
- val md = 24.dp
- val lg = 32.dp
- val xl = 48.dp
-}
-
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
@@ -118,7 +92,7 @@ private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
- PermissionScaffold(
+ OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = false) },
actions = {
@@ -131,7 +105,7 @@ private fun RationaleContent(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
- Spacer(Modifier.width(Space.xs))
+ Spacer(Modifier.width(OnboardingSpace.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
@@ -147,7 +121,7 @@ private fun RationaleContent(
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
- Spacer(Modifier.height(Space.xs))
+ Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
@@ -161,20 +135,20 @@ private fun RationaleContent(
textAlign = TextAlign.Center,
)
- Spacer(Modifier.height(Space.xl))
+ Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.Lock,
title = stringResource(R.string.permission_benefit_private_title),
body = stringResource(R.string.permission_benefit_private_body),
)
- Spacer(Modifier.height(Space.sm))
+ Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.CalendarMonth,
title = stringResource(R.string.permission_benefit_sync_title),
body = stringResource(R.string.permission_benefit_sync_body),
)
- Spacer(Modifier.height(Space.sm))
+ Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.permission_benefit_privacy_title),
@@ -189,7 +163,7 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
- PermissionScaffold(
+ OnboardingScaffold(
modifier = modifier,
hero = { BrandHero(denied = true) },
actions = {
@@ -231,122 +205,6 @@ private fun DeniedContent(
}
}
-/**
- * Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
- * action pinned to the bottom (clear of the navigation bar). The content slot is
- * centred horizontally; benefit rows fill the width so their own content
- * left-aligns.
- */
-@Composable
-private fun PermissionScaffold(
- hero: @Composable () -> Unit,
- actions: @Composable ColumnScope.() -> Unit,
- modifier: Modifier = Modifier,
- body: @Composable ColumnScope.() -> Unit,
-) {
- Scaffold(
- modifier = modifier,
- containerColor = MaterialTheme.colorScheme.surface,
- bottomBar = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .padding(horizontal = Space.md, vertical = Space.sm),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- content = actions,
- )
- },
- ) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .verticalScroll(rememberScrollState())
- .padding(horizontal = Space.md),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Spacer(Modifier.height(Space.xl))
- hero()
- Spacer(Modifier.height(Space.lg))
- body()
- Spacer(Modifier.height(Space.md))
- }
- }
-}
-
-/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
-@Composable
-private fun BrandHero(denied: Boolean) {
- Box(contentAlignment = Alignment.Center) {
- Box(
- modifier = Modifier
- .size(128.dp)
- .clip(RoundedCornerShape(34.dp))
- .background(colorResource(R.color.ic_launcher_background)),
- ) {
- Image(
- painter = painterResource(R.drawable.ic_launcher_foreground),
- contentDescription = stringResource(R.string.app_name),
- modifier = Modifier.fillMaxSize(),
- )
- }
- if (denied) {
- // A small lock badge sits over the corner to signal "blocked".
- Box(
- modifier = Modifier
- .align(Alignment.BottomEnd)
- .offset(x = 10.dp, y = 10.dp)
- .size(44.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.errorContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = Icons.Filled.Lock,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onErrorContainer,
- modifier = Modifier.size(24.dp),
- )
- }
- }
- }
-}
-
-/** One trust point: a tonal icon chip on the left, title + supporting text right. */
-@Composable
-private fun BenefitRow(icon: ImageVector, title: String, body: String) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(44.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(22.dp),
- )
- }
- Spacer(Modifier.width(Space.sm))
- Column(modifier = Modifier.weight(1f)) {
- Text(text = title, style = MaterialTheme.typography.titleMedium)
- Text(
- text = body,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-
@Composable
private fun PrivacyFootnote() {
Row(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt
new file mode 100644
index 0000000..6f20e3c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingScreen.kt
@@ -0,0 +1,138 @@
+package de.jeanlucmakiola.calendula.ui.permission
+
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import de.jeanlucmakiola.calendula.R
+
+/**
+ * One-time onboarding step after the calendar grant (v1.4): explains that
+ * Calendula delivers reminder notifications itself, warns about duplicates
+ * when a second calendar app has notifications on, and requests
+ * `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
+ *
+ * Reminders default ON: [onFinished] gets true from the primary action even
+ * if the system dialog is declined — the OS permission is the real gate, and
+ * the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
+ */
+@Composable
+fun ReminderOnboardingScreen(
+ onFinished: (remindersEnabled: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { onFinished(true) }
+
+ OnboardingScaffold(
+ modifier = modifier,
+ hero = { BellHero() },
+ actions = {
+ Button(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ onFinished(true)
+ }
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.reminder_onboarding_enable_button),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ TextButton(
+ onClick = { onFinished(false) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.reminder_onboarding_skip_button))
+ }
+ },
+ ) {
+ Text(
+ text = stringResource(R.string.app_name).uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ letterSpacing = 2.sp,
+ )
+ Spacer(Modifier.height(OnboardingSpace.xs))
+ Text(
+ text = stringResource(R.string.reminder_onboarding_title),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.reminder_onboarding_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(OnboardingSpace.xl))
+
+ BenefitRow(
+ icon = Icons.Filled.NotificationsActive,
+ title = stringResource(R.string.reminder_benefit_delivery_title),
+ body = stringResource(R.string.reminder_benefit_delivery_body),
+ )
+ Spacer(Modifier.height(OnboardingSpace.sm))
+ BenefitRow(
+ icon = Icons.Filled.ContentCopy,
+ title = stringResource(R.string.reminder_benefit_duplicates_title),
+ body = stringResource(R.string.reminder_benefit_duplicates_body),
+ )
+ Spacer(Modifier.height(OnboardingSpace.sm))
+ BenefitRow(
+ icon = Icons.Filled.Tune,
+ title = stringResource(R.string.reminder_benefit_reversible_title),
+ body = stringResource(R.string.reminder_benefit_reversible_body),
+ )
+ }
+}
+
+/** A bell in the brand squircle — same silhouette as the permission hero. */
+@Composable
+private fun BellHero() {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .clip(RoundedCornerShape(34.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.NotificationsActive,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(56.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt
new file mode 100644
index 0000000..e20bf7b
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/ReminderOnboardingViewModel.kt
@@ -0,0 +1,39 @@
+package de.jeanlucmakiola.calendula.ui.permission
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Gates the one-time reminder onboarding step (v1.4) shown after the calendar
+ * grant. [onboardingDone] is null until DataStore's first emission so the
+ * step neither flashes for users who completed it nor gets skipped.
+ */
+@HiltViewModel
+class ReminderOnboardingViewModel @Inject constructor(
+ private val prefs: SettingsPrefs,
+) : ViewModel() {
+
+ val onboardingDone: StateFlow = prefs.reminderOnboardingDone
+ .map { done -> done as Boolean? }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000L),
+ initialValue = null,
+ )
+
+ /** Close the step, recording whether reminder notifications stay on. */
+ fun finish(remindersEnabled: Boolean) {
+ viewModelScope.launch {
+ prefs.setRemindersEnabled(remindersEnabled)
+ prefs.setReminderOnboardingDone()
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
index 20d8a4f..a7c6e84 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
@@ -1,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.settings
+import android.Manifest
import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,6 +47,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
@@ -128,6 +134,13 @@ fun SettingsScreen(
)
}
+ HorizontalDivider(Modifier.padding(vertical = 8.dp))
+ SectionHeader(stringResource(R.string.settings_section_notifications))
+ RemindersRow(
+ checked = state.remindersEnabled,
+ onCheckedChange = viewModel::setRemindersEnabled,
+ )
+
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow()
@@ -249,6 +262,55 @@ private fun DynamicColorRow(
}
}
+/**
+ * Reminder-notifications toggle (v1.4), mirroring the onboarding step.
+ * Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
+ * the pref is set either way; the OS permission is the real gate.
+ */
+@Composable
+private fun RemindersRow(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ val context = LocalContext.current
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.settings_reminders),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = stringResource(R.string.settings_reminders_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.width(16.dp))
+ Switch(
+ checked = checked,
+ onCheckedChange = { enabled ->
+ onCheckedChange(enabled)
+ val needsPermission = enabled &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(
+ context, Manifest.permission.POST_NOTIFICATIONS,
+ ) != PackageManager.PERMISSION_GRANTED
+ if (needsPermission) {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ },
+ )
+ }
+}
+
@Composable
private fun AboutSection() {
val context = LocalContext.current
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
index 404697c..2047215 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
@@ -18,4 +18,6 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS,
+ /** Whether Calendula posts reminder notifications (v1.4). */
+ val remindersEnabled: Boolean = true,
)
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
index 801cf5c..0d6a730 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
@@ -28,13 +28,15 @@ class SettingsViewModel @Inject constructor(
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
- ) { theme, dynamic, weekStart, formFields ->
+ prefs.remindersEnabled,
+ ) { theme, dynamic, weekStart, formFields, reminders ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
+ remindersEnabled = reminders,
)
}.stateIn(
scope = viewModelScope,
@@ -57,4 +59,8 @@ class SettingsViewModel @Inject constructor(
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
+
+ fun setRemindersEnabled(enabled: Boolean) {
+ viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
+ }
}
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..34e1c4d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 599da52..b3ad9ec 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -159,6 +159,20 @@
(Ohne Titel)
+
+ Termin-Erinnerungen
+ Benachrichtigungen zu den Erinnerungszeiten deiner Termine
+ Keinen Termin mehr verpassen
+ Android zeigt Termin-Erinnerungen nicht von selbst — das muss eine Kalender-App tun. Überlass Calendula den Job.
+ Erinnerungen, zugestellt
+ Jede Erinnerung an deinen Terminen kommt pünktlich als Benachrichtigung an.
+ Noch eine zweite Kalender-App?
+ Wenn eine andere App ebenfalls Erinnerungen zeigt, siehst du sie doppelt — schalte sie dort oder hier ab.
+ Jederzeit änderbar
+ Der Schalter liegt in den Einstellungen unter Benachrichtigungen.
+ Erinnerungen einschalten
+ Später
+
Monat
Woche
@@ -183,6 +197,9 @@
Sonntag
Termin-Formular
Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"
+ Benachrichtigungen
+ Termin-Erinnerungen
+ Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.
Sprache
App-Sprache
Systemstandard
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 396f8ee..57c5c0a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -160,6 +160,20 @@
(No title)
+
+ Event reminders
+ Notifications at the reminder times of your events
+ Never miss an event
+ Android doesn\'t show event reminders by itself — a calendar app has to. Let Calendula take that job.
+ Reminders, delivered
+ Every reminder on your events arrives as a notification, right on time.
+ Using a second calendar app?
+ If another app also posts reminders, you\'ll see them twice — turn them off there or here.
+ Change it anytime
+ The switch lives in Settings, under Notifications.
+ Turn on reminders
+ Not now
+
Month
Week
@@ -184,6 +198,9 @@
Sunday
New event form
Fields shown by default — everything else sits behind \"More fields\"
+ Notifications
+ Event reminders
+ Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.
Language
App language
System default
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
index 364f720..d976f75 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
@@ -100,6 +100,27 @@ class SettingsPrefsTest {
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
}
+ @Test
+ fun `reminders default to enabled, onboarding to not done`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ assertThat(prefs.remindersEnabled.first()).isTrue()
+ assertThat(prefs.reminderOnboardingDone.first()).isFalse()
+ }
+
+ @Test
+ fun `reminders toggle round-trips`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ prefs.setRemindersEnabled(false)
+ assertThat(prefs.remindersEnabled.first()).isFalse()
+ }
+
+ @Test
+ fun `reminder onboarding completes one-way`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ prefs.setReminderOnboardingDone()
+ assertThat(prefs.reminderOnboardingDone.first()).isTrue()
+ }
+
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
new file mode 100644
index 0000000..e17fa80
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt
@@ -0,0 +1,85 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Test
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+import java.util.Locale
+
+class ReminderTimeTextTest {
+
+ private val berlin = ZoneId.of("Europe/Berlin")
+
+ private fun millisAt(dateTime: LocalDateTime, zone: ZoneId): Long =
+ dateTime.atZone(zone).toInstant().toEpochMilli()
+
+ private fun utcMidnight(date: LocalDate): Long =
+ date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
+
+ @Test
+ fun `timed event on one day shows just the time range`() {
+ val text = reminderTimeText(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
+ isAllDay = false,
+ zone = berlin,
+ locale = Locale.GERMANY,
+ )
+ assertThat(text).isEqualTo("09:30 – 10:00")
+ }
+
+ @Test
+ fun `timed event crossing midnight includes both dates`() {
+ val text = reminderTimeText(
+ beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
+ endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
+ isAllDay = false,
+ zone = berlin,
+ locale = Locale.GERMANY,
+ )
+ assertThat(text).contains("11.06.2026")
+ assertThat(text).contains("12.06.2026")
+ assertThat(text).contains("23:30")
+ }
+
+ @Test
+ fun `all-day single day shows one date, read in UTC`() {
+ val text = reminderTimeText(
+ beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
+ endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
+ isAllDay = true,
+ // Zone must not matter for all-day events: UTC midnight is
+ // 02:00 in Berlin — naive local reading would shift the day.
+ zone = berlin,
+ locale = Locale.GERMANY,
+ )
+ assertThat(text).isEqualTo("11.06.2026")
+ }
+
+ @Test
+ fun `all-day multi-day shows the last covered day, not the exclusive end`() {
+ val text = reminderTimeText(
+ beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
+ endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
+ isAllDay = true,
+ zone = berlin,
+ locale = Locale.GERMANY,
+ )
+ assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
+ }
+
+ @Test
+ fun `degenerate all-day range never renders an inverted span`() {
+ val day = utcMidnight(LocalDate.of(2026, 6, 11))
+ val text = reminderTimeText(
+ beginMillis = day,
+ endMillis = day,
+ isAllDay = true,
+ zone = berlin,
+ locale = Locale.GERMANY,
+ )
+ assertThat(text).isEqualTo("11.06.2026")
+ }
+}
diff --git a/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md b/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
new file mode 100644
index 0000000..04bf050
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-04-reminder-notifications.md
@@ -0,0 +1,119 @@
+# 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
+ `` — 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.0–v1.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:**
+- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
+ `exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
+ host=com.android.calendar`; `MainActivity` → `launchMode="singleTop"`
+- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
+- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
+
+**Prefs:**
+- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
+ `reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
+
+**Data layer (`data/reminders/`):**
+- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
+ erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
+ title, location, isAllDay
+- [x] `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)
+- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
+ (Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
+ Category EVENT), Content-PendingIntent auf `MainActivity` mit
+ eventId/begin/end
+- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
+
+**Receiver:**
+- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
+ `goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
+ Notifications systemseitig geblockt; sonst posten → `markFired`
+
+**UI:**
+- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
+ (`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
+- [x] `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`
+- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
+- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
+ Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
+ onNewIntent) in Compose-State
+- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
+ Permission-Request beim Einschalten) + Duplikat-Hinweistext
+
+**Abschluss:**
+- [x] `./gradlew lint test assembleDebug` grün
+- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
+ On-Device-Review