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