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>
This commit is contained in:
2026-06-11 21:23:34 +02:00
parent 301f105fbc
commit b03bd67678
25 changed files with 1184 additions and 155 deletions

View File

@@ -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)

View File

@@ -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")
}
}