From 7ab277b4340f5b4bdefae6f9ccbc3fea129c0934 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 22:21:01 +0200 Subject: [PATCH 01/19] feat(reminders): snooze + dismiss notification actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Snooze" and "Dismiss" action buttons to reminder notifications. The app is otherwise pure provider-broadcast (the Etar model): the calendar provider fires EVENT_REMINDER and we post the notification, then mark the CalendarAlerts row fired. A snoozed reminder has no provider backing — its row is already fired — so snooze self-schedules an exact alarm to re-show the same notification, while primary delivery is left unchanged. - ReminderActionReceiver (not exported): SNOOZE cancels + schedules a re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed or dismissed again. - ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact allow-while-idle fallback if exact alarms are revoked (API 31-32). - ReminderNotifier: two actions + cancel(). - snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker presets 5/10/15/30/60. - Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver. - New ic_notification_snooze/_dismiss drawables, duration plurals, en+de strings, snooze-pref tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 18 +++ .../calendula/data/prefs/SettingsPrefs.kt | 17 +++ .../data/reminders/ReminderActionReceiver.kt | 124 ++++++++++++++++++ .../data/reminders/ReminderNotifier.kt | 23 ++++ .../data/reminders/ReminderSnoozeScheduler.kt | 55 ++++++++ .../calendula/ui/settings/SettingsScreen.kt | 33 +++++ .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 13 +- .../res/drawable/ic_notification_dismiss.xml | 12 ++ .../res/drawable/ic_notification_snooze.xml | 12 ++ app/src/main/res/values-de/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 12 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 17 +++ 13 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt create mode 100644 app/src/main/res/drawable/ic_notification_dismiss.xml create mode 100644 app/src/main/res/drawable/ic_notification_snooze.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c37243e..20737d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,17 @@ --> + + + + + + = store.data.map { prefs -> + (prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1) + } + + suspend fun setSnoozeMinutes(minutes: Int) { + store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) } + } + /** * Per-calendar overrides of [defaultReminderMinutes] for **timed** events, * keyed by calendar id. A calendar **present** in the map overrides the global @@ -243,6 +257,9 @@ class SettingsPrefs @Inject constructor( /** 09:00 as minutes from midnight; the default all-day reminder fire time. */ internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 private const val MINUTES_PER_DAY = 1_440 + internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") + /** Default snooze delay for the notification "Snooze" action. */ + const val DEFAULT_SNOOZE_MINUTES = 10 internal val CALENDAR_REMINDER_OVERRIDE_KEY = stringPreferencesKey("per_calendar_reminder_override") internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt new file mode 100644 index 0000000..b0a57fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +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 + +/** + * Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus + * the internal re-show when a snooze elapses. All three are app-internal + * intents (notification action buttons and our own [ReminderSnoozeScheduler] + * alarm), so the receiver is not exported. + * + * - **Dismiss** just cancels the notification — the `CalendarAlerts` row is + * already fired, so nothing re-posts it. + * - **Snooze** cancels the notification and schedules an exact alarm to re-show + * it after the user's snooze delay. + * - **Show** (the alarm) re-posts the same notification, so the user can snooze + * or dismiss it again. + */ +@AndroidEntryPoint +class ReminderActionReceiver : BroadcastReceiver() { + + @Inject lateinit var notifier: ReminderNotifier + @Inject lateinit var scheduler: ReminderSnoozeScheduler + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onReceive(context: Context, intent: Intent) { + val alert = alertFrom(intent) ?: return + when (intent.action) { + ACTION_DISMISS -> notifier.cancel(alert) + + ACTION_SNOOZE -> { + // Cancel now so the notification doesn't linger until the alarm; + // the snooze delay read is the only async work. + notifier.cancel(alert) + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val minutes = settingsPrefs.snoozeMinutes.first() + val triggerAt = System.currentTimeMillis() + minutes * 60_000L + scheduler.schedule(alert, triggerAt) + } finally { + pendingResult.finish() + } + } + } + + ACTION_SHOW -> { + val pendingResult = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) { + notifier.post(alert) + } + } finally { + pendingResult.finish() + } + } + } + } + } + + companion object { + const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE" + const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS" + const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW" + + private const val EXTRA_ALERT_ID = "alert_id" + private const val EXTRA_EVENT_ID = "event_id" + private const val EXTRA_BEGIN = "begin" + private const val EXTRA_END = "end" + private const val EXTRA_TITLE = "title" + private const val EXTRA_LOCATION = "location" + private const val EXTRA_ALL_DAY = "all_day" + + /** An explicit intent to this receiver carrying [alert] as extras. */ + fun intent(context: Context, action: String, alert: ReminderAlert): Intent = + Intent(context, ReminderActionReceiver::class.java).apply { + this.action = action + putExtra(EXTRA_ALERT_ID, alert.alertId) + putExtra(EXTRA_EVENT_ID, alert.eventId) + putExtra(EXTRA_BEGIN, alert.beginMillis) + putExtra(EXTRA_END, alert.endMillis) + putExtra(EXTRA_TITLE, alert.title) + putExtra(EXTRA_LOCATION, alert.location) + putExtra(EXTRA_ALL_DAY, alert.isAllDay) + } + + /** + * A stable request code per (alert, action) so the three PendingIntents + * of one notification stay distinct and don't clobber each other. + */ + fun requestCode(alert: ReminderAlert, action: String): Int { + val actionOffset = when (action) { + ACTION_SNOOZE -> 1 + ACTION_DISMISS -> 2 + ACTION_SHOW -> 3 + else -> 0 + } + return alert.alertId.toInt() * 8 + actionOffset + } + + private fun alertFrom(intent: Intent): ReminderAlert? { + if (!intent.hasExtra(EXTRA_ALERT_ID)) return null + return ReminderAlert( + alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), + eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), + beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), + endMillis = intent.getLongExtra(EXTRA_END, 0L), + title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), + location = intent.getStringExtra(EXTRA_LOCATION), + isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false), + ) + } + } +} 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 index 5c60011..358d9ca 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor( .setAutoCancel(true) .setOnlyAlertOnce(true) .setContentIntent(detailIntent(alert)) + .addAction( + R.drawable.ic_notification_snooze, + context.getString(R.string.reminder_action_snooze), + actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE), + ) + .addAction( + R.drawable.ic_notification_dismiss, + context.getString(R.string.reminder_action_dismiss), + actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS), + ) .build() try { NotificationManagerCompat.from(context) @@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor( } } + /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ + fun cancel(alert: ReminderAlert) { + NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID) + } + + private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = + PendingIntent.getBroadcast( + context, + ReminderActionReceiver.requestCode(alert, action), + ReminderActionReceiver.intent(context, action, alert), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity( context, /* requestCode = */ alert.alertId.toInt(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt new file mode 100644 index 0000000..4bb78f5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Schedules a one-off exact alarm that re-shows a snoozed reminder. + * + * The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER` + * broadcast (the Etar model), but a snoozed reminder has no provider backing — + * its `CalendarAlerts` row is already fired — so we must re-fire it ourselves. + * A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall + * back to an inexact allow-while-idle alarm only if the OS withholds the + * exact-alarm capability (API 31–32 where the user revoked it). + */ +@Singleton +class ReminderSnoozeScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun schedule(alert: ReminderAlert, triggerAtMillis: Long) { + val alarmManager = context.getSystemService() ?: return + val pendingIntent = PendingIntent.getBroadcast( + context, + ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW), + ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + if (canScheduleExact(alarmManager)) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, + ) + } else { + // Exact alarms revoked (API 31–32): an inexact wake is the honest + // best we can do without nagging for SCHEDULE_EXACT_ALARM. + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent, + ) + } + } + + /** + * True on API < 31 (no restriction), and on 31+ when the exact-alarm + * capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+ + * (Calendula is a calendar app), user-revocable on 31–32. + */ + private fun canScheduleExact(alarmManager: AlarmManager): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms() +} 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 4d1a06f..d5e8f63 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 @@ -63,6 +63,7 @@ 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -546,6 +547,7 @@ private fun NotificationsScreen( var showDefaultReminder by remember { mutableStateOf(false) } var showAllDayReminder by remember { mutableStateOf(false) } var showAllDayReminderTime by remember { mutableStateOf(false) } + var showSnooze by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } var expandedCalendars by remember { mutableStateOf(emptySet()) } @@ -666,8 +668,27 @@ private fun NotificationsScreen( }, onClick = { openBatteryOptimizationSettings(context) }, ) + + // Snooze: how long the notification's "Snooze" action defers a reminder. + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_snooze_duration), + summary = snoozeDurationLabel(state.snoozeMinutes), + position = Position.Alone, + onClick = { showSnooze = true }, + ) } + if (showSnooze) { + OptionPicker( + title = stringResource(R.string.settings_snooze_duration), + options = SNOOZE_PRESETS, + selected = state.snoozeMinutes, + label = { snoozeDurationLabel(it) }, + onSelect = { viewModel.setSnoozeMinutes(it) }, + onDismiss = { showSnooze = false }, + ) + } if (showDefaultReminder) { ReminderDefaultPicker( title = stringResource(R.string.settings_default_reminder), @@ -792,6 +813,18 @@ private fun openBatteryOptimizationSettings(context: Context) { */ private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) +/** Snooze delays offered for the notification "Snooze" action, in minutes. */ +private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) + +/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */ +@Composable +private fun snoozeDurationLabel(minutes: Int): String = + if (minutes % 60 == 0) { + pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) + } else { + pluralStringResource(R.plurals.duration_minutes, minutes, minutes) + } + /** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String { val time = Calendar.getInstance().apply { 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 245c296..5d88985 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 @@ -30,6 +30,8 @@ data class SettingsUiState( val defaultAllDayReminderMinutes: Int? = null, /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, + /** How long the notification "Snooze" action defers a reminder; default 10 min. */ + val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, /** * Per-calendar overrides of [defaultReminderMinutes] for timed events: a * calendar present in the map overrides the global default (null value = no 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 5ebeb2b..eead6f9 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 @@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor( prefs.defaultReminderMinutes, prefs.defaultAllDayReminderMinutes, prefs.allDayReminderTimeMinutes, - ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime -> - ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime) + prefs.snoozeMinutes, + ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze -> + ReminderDefaults( + allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze, + ) }, combine( prefs.perCalendarReminderOverride, @@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor( defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, allDayReminderTimeMinutes = defaults.allDayReminderTime, + snoozeMinutes = defaults.snoozeMinutes, perCalendarReminderOverride = overrides.timed, perCalendarAllDayReminderOverride = overrides.allDay, writableCalendars = overrides.calendars, @@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor( val defaultReminder: Int?, val allDayReminder: Int?, val allDayReminderTime: Int, + val snoozeMinutes: Int, ) private data class ReminderOverrides( @@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) } } + fun setSnoozeMinutes(minutes: Int) { + viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } + } + fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) { viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) } } diff --git a/app/src/main/res/drawable/ic_notification_dismiss.xml b/app/src/main/res/drawable/ic_notification_dismiss.xml new file mode 100644 index 0000000..81973d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_dismiss.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_notification_snooze.xml b/app/src/main/res/drawable/ic_notification_snooze.xml new file mode 100644 index 0000000..478352a --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_snooze.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 4ec4c11..a6ee216 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -177,6 +177,15 @@ %d Woche vorher %d Wochen vorher + + + %d Minute + %d Minuten + + + %d Stunde + %d Stunden + (Ohne Titel) @@ -194,6 +203,8 @@ Der Schalter liegt in den Einstellungen unter Benachrichtigungen. Erinnerungen einschalten Später + Schlummern + Verwerfen Monat @@ -265,6 +276,7 @@ Zuverlässige Zustellung Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen. Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich. + Schlummerdauer Kalender Kalender verwalten Lokale Kalender anlegen, synchronisierte verwalten diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..28c40a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -178,6 +178,15 @@ %d week before %d weeks before + + + %d minute + %d minutes + + + %d hour + %d hours + (No title) @@ -195,6 +204,8 @@ The switch lives in Settings, under Notifications. Turn on reminders Not now + Snooze + Dismiss Month @@ -262,6 +273,7 @@ Reliable delivery Android may delay reminders to save battery. Exempt Calendula so they arrive on time. Exempt from battery optimisation — reminders arrive on time. + Snooze duration Calendars Manage calendars Create local calendars; manage synced ones 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 50ab6d8..dee5554 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 @@ -233,6 +233,23 @@ class SettingsPrefsTest { assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0) } + @Test + fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(10) + } + + @Test + fun `snooze duration round-trips and clamps to at least one minute`( + @TempDir tempDir: Path, + ) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSnoozeMinutes(30) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(30) + prefs.setSnoozeMinutes(0) + assertThat(prefs.snoozeMinutes.first()).isEqualTo(1) + } + @Test fun `explicit week-start prefs resolve regardless of locale`() { assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) From 006cce96a909bb6ef12efc5339f50238046fead7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 21 Jun 2026 21:52:19 +0200 Subject: [PATCH 02/19] feat(crash): hand off reports by email instead of the Gitea issue tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filing a Gitea issue requires an account on the instance, so anonymous reporters hit a login wall — Gitea has no anonymous issue creation. Switch both the crash-report and manual problem-report paths to compose a pre-addressed email via ACTION_SENDTO (mailto:), which needs no account and preserves the existing no-INTERNET, user-sends-it-themselves model. The full report rides in EXTRA_TEXT, so the old URL-length cap and clipboard-paste fallback are gone (clipboard copy stays as a safety net). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/crash/CrashReportSubmit.kt | 67 +++++++++---------- .../calendula/ui/settings/SettingsScreen.kt | 12 ++-- app/src/main/res/values-de/strings.xml | 9 +-- app/src/main/res/values/strings.xml | 14 ++-- 4 files changed, 49 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt index 1477beb..80b7259 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt @@ -9,25 +9,44 @@ import androidx.core.net.toUri import de.jeanlucmakiola.calendula.R /** - * Hand the captured crash report off to the user's chosen channel: the report - * is copied to the clipboard (the reliable path for a full stack trace) and the - * project's Gitea "new issue" page is opened with the body prefilled. Nothing is - * sent automatically — the app has no network access; the user reviews and - * submits the issue themselves. + * Hand the captured crash report off to the user's email app: the report is + * copied to the clipboard (a safety net) and a pre-addressed email is composed + * with the report as the body. Nothing is sent automatically — the app has no + * network access; the user reviews and sends the mail themselves. + * + * Email is used rather than the web issue tracker because filing a Gitea issue + * requires an account on the instance, which anonymous reporters don't have — + * whereas anyone can send an email from the app they already use. */ fun submitCrashReport(context: Context, report: String) { copyReportToClipboard(context, report) - val opened = runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report))) - }.isSuccess + val subject = context.getString(R.string.crash_report_issue_title) + val body = context.getString(R.string.crash_report_body_template, "```\n$report\n```") + val opened = sendReportEmail(context, subject, body) val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed Toast.makeText(context, message, Toast.LENGTH_LONG).show() } -/** Open the issue tracker's template chooser for a manual (non-crash) report. */ -fun openIssueTracker(context: Context) { - val uri = context.getString(R.string.report_issue_choose_url).toUri() - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } +/** Compose a fresh problem-report email for a manual (non-crash) report. */ +fun reportIssueByEmail(context: Context) { + val subject = context.getString(R.string.report_issue_subject) + val body = context.getString(R.string.report_issue_body_template) + sendReportEmail(context, subject, body) +} + +/** + * Open the user's email app with a pre-addressed, prefilled message. The + * `mailto:` data URI guarantees only email apps resolve the intent (a plain + * ACTION_SEND would also offer messengers). The body rides in an extra rather + * than the URI, so there is no URL-length ceiling on the report. + */ +private fun sendReportEmail(context: Context, subject: String, body: String): Boolean { + val email = context.getString(R.string.report_issue_email) + val intent = Intent(Intent.ACTION_SENDTO, "mailto:$email".toUri()).apply { + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + } + return runCatching { context.startActivity(intent) }.isSuccess } private fun copyReportToClipboard(context: Context, report: String) { @@ -35,27 +54,3 @@ private fun copyReportToClipboard(context: Context, report: String) { val label = context.getString(R.string.crash_report_clip_label) clipboard.setPrimaryClip(ClipData.newPlainText(label, report)) } - -/** - * The Gitea `issues/new` URL with `title` and `body` prefilled. A full report - * can blow past URL-length limits, so an over-long one is left out of the link - * (with a "paste from clipboard" placeholder) — the clipboard copy is the - * source of truth in that case. - */ -private fun buildIssueUri(context: Context, report: String) = - context.getString(R.string.report_issue_url).toUri().buildUpon() - .appendQueryParameter("title", context.getString(R.string.crash_report_issue_title)) - .appendQueryParameter("body", buildIssueBody(context, report)) - .build() - -private fun buildIssueBody(context: Context, report: String): String { - val block = if (report.length > MAX_URL_REPORT_CHARS) { - context.getString(R.string.crash_report_body_paste) - } else { - "```\n$report\n```" - } - return context.getString(R.string.crash_report_body_template, block) -} - -/** Keep the prefilled body comfortably under common URL-length ceilings. */ -private const val MAX_URL_REPORT_CHARS = 6_000 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 4d1a06f..f7c6cc6 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 @@ -80,7 +80,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog -import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker +import de.jeanlucmakiola.calendula.ui.crash.reportIssueByEmail import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow @@ -209,10 +209,10 @@ private fun SettingsHub( } /** - * Opens the project's issue tracker to report a problem. If a crash report was - * captured (and not yet sent), it surfaces that report first via the same - * dialog the next-launch prompt uses; otherwise it opens the issue template - * chooser. No data leaves the device until the user submits the issue. + * Composes a problem-report email. If a crash report was captured (and not yet + * sent), it surfaces that report first via the same dialog the next-launch + * prompt uses; otherwise it opens a blank report email. No data leaves the + * device until the user sends the mail themselves. */ @Composable private fun ReportProblemRow(position: Position) { @@ -226,7 +226,7 @@ private fun ReportProblemRow(position: Position) { leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, onClick = { val pending = CrashReporter.pendingReport(context) - if (pending != null) report = pending else openIssueTracker(context) + if (pending != null) report = pending else reportIssueByEmail(context) }, ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ec4c11..3fdeba9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -283,7 +283,7 @@ Version %1$s Calendula-App-Symbol Problem melden - Absturzbericht senden oder Issue-Tracker öffnen + Absturzbericht oder Feedback per E-Mail senden Kalender @@ -347,8 +347,9 @@ Nicht jetzt Absturzbericht Calendula-Absturzbericht - Bericht in die Zwischenablage kopiert - Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage. + Bericht kopiert — E-Mail-App wird geöffnet + Keine E-Mail-App gefunden. Der Bericht ist in deiner Zwischenablage. Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n - _(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_ + Calendula — Problemmeldung + Bitte beschreibe das aufgetretene Problem und was du gerade getan hast.\n\n diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..0be0675 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,7 +280,7 @@ Version %1$s Calendula app icon Report a problem - Send a crash report or open the issue tracker + Send a crash report or feedback by email Calendars @@ -351,10 +351,10 @@ Not now Crash report Calendula crash report - Report copied to your clipboard - Couldn\'t open the issue tracker. The report is on your clipboard. - Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n - _(The report was too long for this link — paste it from your clipboard here.)_ - https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new - https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose + Report copied — opening your email app + No email app found. The report is on your clipboard. + Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then send.\n\n### What happened\n\n\n### Crash report\n%1$s\n + mail@jeanlucmakiola.de + Calendula — problem report + Please describe the problem you ran into, and what you were doing when it happened.\n\n From 37452be3bd6b4ee820b0ef78e2e12bcf049e540c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 10:45:54 +0200 Subject: [PATCH 03/19] feat(crash): file reports via the public Codeberg tracker instead of email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that a public, writable issue tracker exists (the Codeberg mirror at codeberg.org/jlmakiola/calendula, which the Gitea issue tab links to), revert the email hand-off (6150ce6) back to the in-app issue-creation flow: the crash path opens a prefilled issues/new page (with clipboard copy as the long-report fallback) and the manual "Report a problem" path opens the issue template chooser. The email pivot existed only because the personal Gitea has no public issue creation (reporters hit a login wall). Codeberg lets anyone register and file, so that reason is gone. Still no INTERNET permission — the user submits via the browser themselves. URLs point at Codeberg directly so the prefilled title/body survive (a Gitea external-tracker redirect wouldn't carry query params). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/crash/CrashReportSubmit.kt | 67 ++++++++++--------- .../calendula/ui/settings/SettingsScreen.kt | 12 ++-- app/src/main/res/values-de/strings.xml | 9 ++- app/src/main/res/values/strings.xml | 14 ++-- 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt index 80b7259..1477beb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/crash/CrashReportSubmit.kt @@ -9,44 +9,25 @@ import androidx.core.net.toUri import de.jeanlucmakiola.calendula.R /** - * Hand the captured crash report off to the user's email app: the report is - * copied to the clipboard (a safety net) and a pre-addressed email is composed - * with the report as the body. Nothing is sent automatically — the app has no - * network access; the user reviews and sends the mail themselves. - * - * Email is used rather than the web issue tracker because filing a Gitea issue - * requires an account on the instance, which anonymous reporters don't have — - * whereas anyone can send an email from the app they already use. + * Hand the captured crash report off to the user's chosen channel: the report + * is copied to the clipboard (the reliable path for a full stack trace) and the + * project's Gitea "new issue" page is opened with the body prefilled. Nothing is + * sent automatically — the app has no network access; the user reviews and + * submits the issue themselves. */ fun submitCrashReport(context: Context, report: String) { copyReportToClipboard(context, report) - val subject = context.getString(R.string.crash_report_issue_title) - val body = context.getString(R.string.crash_report_body_template, "```\n$report\n```") - val opened = sendReportEmail(context, subject, body) + val opened = runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report))) + }.isSuccess val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed Toast.makeText(context, message, Toast.LENGTH_LONG).show() } -/** Compose a fresh problem-report email for a manual (non-crash) report. */ -fun reportIssueByEmail(context: Context) { - val subject = context.getString(R.string.report_issue_subject) - val body = context.getString(R.string.report_issue_body_template) - sendReportEmail(context, subject, body) -} - -/** - * Open the user's email app with a pre-addressed, prefilled message. The - * `mailto:` data URI guarantees only email apps resolve the intent (a plain - * ACTION_SEND would also offer messengers). The body rides in an extra rather - * than the URI, so there is no URL-length ceiling on the report. - */ -private fun sendReportEmail(context: Context, subject: String, body: String): Boolean { - val email = context.getString(R.string.report_issue_email) - val intent = Intent(Intent.ACTION_SENDTO, "mailto:$email".toUri()).apply { - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, body) - } - return runCatching { context.startActivity(intent) }.isSuccess +/** Open the issue tracker's template chooser for a manual (non-crash) report. */ +fun openIssueTracker(context: Context) { + val uri = context.getString(R.string.report_issue_choose_url).toUri() + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) } } private fun copyReportToClipboard(context: Context, report: String) { @@ -54,3 +35,27 @@ private fun copyReportToClipboard(context: Context, report: String) { val label = context.getString(R.string.crash_report_clip_label) clipboard.setPrimaryClip(ClipData.newPlainText(label, report)) } + +/** + * The Gitea `issues/new` URL with `title` and `body` prefilled. A full report + * can blow past URL-length limits, so an over-long one is left out of the link + * (with a "paste from clipboard" placeholder) — the clipboard copy is the + * source of truth in that case. + */ +private fun buildIssueUri(context: Context, report: String) = + context.getString(R.string.report_issue_url).toUri().buildUpon() + .appendQueryParameter("title", context.getString(R.string.crash_report_issue_title)) + .appendQueryParameter("body", buildIssueBody(context, report)) + .build() + +private fun buildIssueBody(context: Context, report: String): String { + val block = if (report.length > MAX_URL_REPORT_CHARS) { + context.getString(R.string.crash_report_body_paste) + } else { + "```\n$report\n```" + } + return context.getString(R.string.crash_report_body_template, block) +} + +/** Keep the prefilled body comfortably under common URL-length ceilings. */ +private const val MAX_URL_REPORT_CHARS = 6_000 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 f7c6cc6..4d1a06f 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 @@ -80,7 +80,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog -import de.jeanlucmakiola.calendula.ui.crash.reportIssueByEmail +import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.GroupedRow @@ -209,10 +209,10 @@ private fun SettingsHub( } /** - * Composes a problem-report email. If a crash report was captured (and not yet - * sent), it surfaces that report first via the same dialog the next-launch - * prompt uses; otherwise it opens a blank report email. No data leaves the - * device until the user sends the mail themselves. + * Opens the project's issue tracker to report a problem. If a crash report was + * captured (and not yet sent), it surfaces that report first via the same + * dialog the next-launch prompt uses; otherwise it opens the issue template + * chooser. No data leaves the device until the user submits the issue. */ @Composable private fun ReportProblemRow(position: Position) { @@ -226,7 +226,7 @@ private fun ReportProblemRow(position: Position) { leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, onClick = { val pending = CrashReporter.pendingReport(context) - if (pending != null) report = pending else reportIssueByEmail(context) + if (pending != null) report = pending else openIssueTracker(context) }, ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3fdeba9..4ec4c11 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -283,7 +283,7 @@ Version %1$s Calendula-App-Symbol Problem melden - Absturzbericht oder Feedback per E-Mail senden + Absturzbericht senden oder Issue-Tracker öffnen Kalender @@ -347,9 +347,8 @@ Nicht jetzt Absturzbericht Calendula-Absturzbericht - Bericht kopiert — E-Mail-App wird geöffnet - Keine E-Mail-App gefunden. Der Bericht ist in deiner Zwischenablage. + Bericht in die Zwischenablage kopiert + Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage. Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n - Calendula — Problemmeldung - Bitte beschreibe das aufgetretene Problem und was du gerade getan hast.\n\n + _(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0be0675..b7c3e75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,7 +280,7 @@ Version %1$s Calendula app icon Report a problem - Send a crash report or feedback by email + Send a crash report or open the issue tracker Calendars @@ -351,10 +351,10 @@ Not now Crash report Calendula crash report - Report copied — opening your email app - No email app found. The report is on your clipboard. - Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then send.\n\n### What happened\n\n\n### Crash report\n%1$s\n - mail@jeanlucmakiola.de - Calendula — problem report - Please describe the problem you ran into, and what you were doing when it happened.\n\n + Report copied to your clipboard + Couldn\'t open the issue tracker. The report is on your clipboard. + Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n + _(The report was too long for this link — paste it from your clipboard here.)_ + https://codeberg.org/jlmakiola/calendula/issues/new + https://codeberg.org/jlmakiola/calendula/issues/new/choose From 62ebd48e3c776cec22b45027c7b1af148e1516f4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 22:13:36 +0200 Subject: [PATCH 04/19] docs(changelog): note snooze/dismiss reminder actions Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7f709..e1a51a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Snooze and dismiss buttons on reminder notifications. Dismiss clears the + reminder; snooze hides it and brings it back after a delay you pick in + Settings → Notifications (5 to 60 minutes, default 10). Android's calendar + system won't re-post a reminder on its own, so Calendula schedules an exact + alarm to bring a snoozed one back on time. + ## [2.7.5] — 2026-06-21 ### Changed From 4ad1c09f8fe6ec638d2cb517fcb2d838092ca441 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:00:34 +0200 Subject: [PATCH 05/19] feat(detail): show attendee email under the name The attendee row only used the email as a fallback when the name was blank, so a guest with both a name and an email never showed the email. Add it as a supporting line (bodySmall / onSurfaceVariant) beneath the name, kept off rows whose headline already falls back to the email. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/detail/EventDetailScreen.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index e19ca18..c3e4d0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -590,10 +590,22 @@ private fun AttendeeRow(attendee: Attendee) { verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { + val hasName = attendee.name.isNotBlank() Text( - text = attendee.name.ifBlank { attendee.email.orEmpty() }, + text = if (hasName) attendee.name else attendee.email.orEmpty(), style = MaterialTheme.typography.bodyMedium, ) + // Email as supporting text — only when the name is the headline, so we + // don't repeat it on rows that already fall back to the email above. + if (hasName) { + attendee.email?.takeIf { it.isNotBlank() }?.let { email -> + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } attendeeRoleLabel(attendee)?.let { roleRes -> Text( text = stringResource(roleRes), From 92ec718b28689d5dd68fd190fa0939079bedc675 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:00:45 +0200 Subject: [PATCH 06/19] docs(roadmap): sync state and promote attendee editing to Tier 5 Bring the planning docs up to date with reality from the changelog and recent merges: - Mark the .ics engine (export + import) shipped in v2.7.0 and local- calendar backup done; clear the stale "in progress" tags. - Note snooze/dismiss merged into release/v2.8.0 alongside Codeberg crash reports. - Record drag-and-drop rescheduling as consciously rejected. - Promote attendee editing out of the gated bucket into Tier 5 #12 (read side already shipped in v0.6; only the write side is missing), with the sync-adapter invitation caveat to resolve first. - Refresh STATE.md (was stuck at v2.4) through v2.7.5 and the 2.8.0 integration branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 110 ++++++++++++++++++++++++++++++------------- .planning/STATE.md | 76 ++++++++++++++++++++++++------ 2 files changed, 137 insertions(+), 49 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f194096..e2b0288 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -236,22 +236,34 @@ pass on the existing controls; new toggles ride in with their own features. 9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global default reminder **+ per-calendar override**, bundled with battery-exemption hardening. Full sketch in "Reminders — defaults & delivery reliability" below. -10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one +10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`), - four surfaces: single-event share + whole-calendar backup (export), - open-`.ics`→form + whole-calendar restore (import). Closes the - device-local-calendar data-loss gap (#10/#11 merged here). Built as **two - sequential branches in one release**: `feat/ics-export` (write side + - UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup). - Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / - `RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`, - non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored - `EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on - import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`. -11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the - `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze - must re-fire an alarm). -12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog) + four surfaces all shipped: single-event share + whole-calendar backup + (export), open-`.ics`→form + whole-calendar bulk import (import). Closed the + device-local-calendar data-loss gap (#10/#11 merged here). Built as two + sequential branches: `feat/ics-export` (write side + UID-on-create precursor) + then `feat/ics-import` (parser, restore, dedup by UID). Import is + liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID` + / guest lists it can't model. Plans: + `docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`. +11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* — + followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager + decision (snooze must re-fire an alarm). + +**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)* +12. **Attendee editing** *(next — promoted from the gated "Locations & People" + bucket, owner says high-importance 2026-06-22)* — attendees are already + *read* (queried, mapped, shown on the detail screen since v0.6) but the + event form can't *write* them, the last big read-only gap in the event + model. Add an attendees section to `EventEditScreen` / `EventForm`: add by + typed email (contact-picker entry can follow later), edit/remove rows, set + role (required / optional) — writing `CalendarContract.Attendees` rows on + insert + dirty-checked update, mirroring the reminders-diff pattern. + **Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter + *invitation* behavior that differs by backend (Google auto-emails invites; + DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) — + decide and document what Calendula promises before building. Full sketch in + "Attendee editing" under Locations & People below. **Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** - Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) @@ -263,11 +275,13 @@ pass on the existing controls; new toggles ride in with their own features. 2026-06-17; cheap but low value, pick up only if asked **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts, -full-text search, ICS file import. Pulled in opportunistically, not sequenced. +full-text search. Pulled in opportunistically, not sequenced. -Debatable calls worth a second look: whether **local-calendar backup (#10)** -should lead Tier 4 outright (it's a silent data-loss risk, not a feature); -whether drag-drop (#12) jumps ahead given its daily-driver impact. +Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics` +export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected). +**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap +in the event model). After it: the theme-group ideas and Tier 2/3 leftovers +(quick-settings tile, now-line, week numbers in month, full-text search). ## Navigation & views @@ -288,8 +302,6 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact. ## Event editing & creation -- Drag & drop rescheduling in day/week (recurring drops reuse the scope - dialog) — big-ticket, own slice - Duplicate event (detail action → prefilled create form) - **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form) *(next)* — chosen to follow the in-progress tap-to-create + calendar @@ -319,13 +331,11 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact. go/no-go gate as the OSM/INTERNET item below. - Move event to another calendar (copy+delete model with a consequences warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)* -- **Local-calendar backup / export** *(Tier 4 #10)* — device-only - (`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no - sync and therefore no backup**: a lost/wiped phone destroys them permanently. - Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus - restore-on-import that recreates events into a chosen local calendar. Reuses - the .ics serializer from the single-event share work; the restore path reuses - the import parser. A data-integrity obligation, not a feature. +- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** — + device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no + backup. Settings → Calendars → Export writes every event to a user-chosen + `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by + UID). Closed the silent data-loss gap. ## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)* @@ -419,9 +429,11 @@ it directly undermines the feature's premise, so it rides in here. ## Sharing & interop -- Share event as .ics + open/receive .ics into a prefilled create form - (front-runs the import below) -- ICS file import (drag-and-drop) *(was v3.0, optional)* +- ~~Share event as .ics + open/receive .ics into a prefilled create form~~ + **shipped v2.7.0** — single-event share from detail; opening an `.ics` with one + event prefills the create form, many events opens a bulk import (dedup by UID) +- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics` + flow above (single → form, many → bulk import) ## Platform & launchers @@ -455,9 +467,37 @@ in detail yet: trade-off is an explicit go/no-go decision before any work starts. - **Inline contact suggestions** while typing (needs READ_CONTACTS) — only if the picker proves clunky. -- **Attendee editing / invites from contacts** — own milestone; writing - `Attendees` rows touches sync-adapter invitation behavior (Google vs - DAVx5 differ). +- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now + Tier 5 #12, high-importance; the no-permission typed-email path is not an + INTERNET/contacts call)*. See the "Attendee editing" sketch below. + +### Attendee editing *(Tier 5 #12, opened 2026-06-22)* + +The last read-only gap in the event model: attendees are read & shown on the +detail screen (since v0.6) but the form can't write them. Make guests editable. + +- **Read side already done:** `Attendee` domain model + status/relationship/type + enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the + attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add + there. +- **Write side (new):** add `attendees` to `EventForm` and an attendees section + to `EventEditScreen` — add a guest by typed email (+ optional display name), + remove rows, set role (required / optional). Persist by diffing against the + provider's `CalendarContract.Attendees` rows on insert + dirty-checked update, + mirroring the reminders-diff (kept rows keep their fields). Organizer/self rows + are not user-editable. +- **No new permission for the typed-email path** — writing `Attendees` needs only + the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, + one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- + picker mechanism above. +- **Sync-adapter caveat — decide before building:** writing `Attendees` rows + triggers backend-specific *invitation* behavior. Google's provider may auto-mail + invites; CalDAV/DAVx5 writes the `ATTENDEE` property and leaves delivery to the + server. Calendula sends nothing itself (no INTERNET), so what actually reaches a + guest depends entirely on the sync adapter. Settle and document the honest + promise (and any "your account may email this guest" copy) up front. +- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups, + resource booking — all carry server round-trips or richer sync semantics. ## Consciously rejected @@ -465,3 +505,5 @@ in detail yet: - Natural-language quick entry (high effort, locale-fragile; the prefilled form already covers fast entry) - Quick-add sheet (the prefilled full form already covers it — cut in v2.0) +- Drag & drop rescheduling in day/week — **rejected** (owner decision, + reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form. diff --git a/.planning/STATE.md b/.planning/STATE.md index 28e7971..cbae6fa 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,17 +1,27 @@ # Calendula — Current State -*Last updated: 2026-06-17* +*Last updated: 2026-06-22* ## Status -**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11; -v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15. -**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local -calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings, -the calendar manager and the navigation drawer) both shipped 2026-06-16; -v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen -agenda + month widgets, and a "New event" launcher shortcut) shipped -2026-06-17. The backlog is now organised by theme in `ROADMAP.md`. +**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11. +**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`). +**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/ +interop) arc is now done or in flight: +- v2.4.0 per-event colors (2026-06-17) +- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New + event" launcher shortcut (2026-06-17) +- v2.6.0 default reminders (global + per-calendar override, all-day default, + battery-exemption row) + system per-app language (2026-06-18) +- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export, + open/receive `.ics` (single → form, many → bulk import, dedup by UID) + (2026-06-18) +- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes + +**Next release `release/v2.8.0` (integration branch, not yet cut to main):** +holds crash reports via the public Codeberg tracker (MR !27) + reminder +snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at +release-cut. ## Progress @@ -120,11 +130,47 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped sync" warning on the picker and in Settings. Color writes flow through insert / dirty-checked update / occurrence-exception; mapper + form tests. +- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view), + jump-to-date drawer date picker, two home-screen widgets (scrolling + "Upcoming" agenda + month grid), and a "New event" launcher long-press + shortcut + +- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default + + separate all-day default + per-calendar override (timed), applied on create + with dirty-flag handling; three pickers + override list in Settings → + Notifications; battery-optimisation exemption row (status + system deep-link, + no new permission). Plus system per-app language (Android 13+) and an + immediate-effect fix for the in-app language picker + +- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as + `.ics` from the detail screen; back up local calendars (Settings → Calendars + → Export) to a SAF file; open/receive an `.ics` — one event prefills the + create form, many events open a bulk import into a chosen calendar (dedup by + UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests). + Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and + a widget R8 keep-rule crash fix + +- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant), + user-controlled crash reporting, widget loading-spinner R8 keep rule, and + F-Droid reproducible-build cleanups for the official repo + +- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg + tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28, + snooze self-schedules an exact alarm; primary delivery stays provider-broadcast) + ## Next -1. Monitor the F-Droid build/publish for the v2.4.0 tag -2. Decide the "Locations & People" and "remote calendar create/edit" - go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` -3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then - agenda view (strategic, backs a future widget). Full ranked sequence in - `ROADMAP.md` → "Near-term sequence". +1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the + merge-driven pipeline) once on-device review signs off +2. **Attendee editing** — the committed next feature (Tier 5 #12, high- + importance, opened 2026-06-22). Attendees are already read & shown on the + detail screen since v0.6; the gap is the write side — make guests editable + in `EventEditScreen` / `EventForm` (add by typed email, role, remove), + persisted by diffing `CalendarContract.Attendees`. No new permission for the + typed-email path. **Resolve first:** the backend-specific invitation/sync + behavior (Google auto-mails invites, CalDAV writes `ATTENDEE`) — decide and + document the honest promise. Full sketch in `ROADMAP.md` → "Attendee editing". +3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar + create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week + numbers in month, full-text search, accessibility pass). Drag-and-drop + rescheduling is **rejected**. From ab8a9e34016c14b23eba010e23e8dde5b340e2b6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:05:11 +0200 Subject: [PATCH 07/19] docs(roadmap): settle attendee-invitation behavior (record-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision (owner, 2026-06-22): attendee editing is record-only on any writable calendar. Calendula has no INTERNET and never sends an invitation itself — it only writes Attendees rows. Notification is decided downstream (local: no one; CalDAV: server iMIP; Google: Google). Mandatory backend-aware copy makes this honest. No fabricated ORGANIZER; the optional "send .ics via email app" delegate is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 23 +++++++++++++++++------ .planning/STATE.md | 7 ++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e2b0288..225368d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -490,12 +490,23 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- picker mechanism above. -- **Sync-adapter caveat — decide before building:** writing `Attendees` rows - triggers backend-specific *invitation* behavior. Google's provider may auto-mail - invites; CalDAV/DAVx5 writes the `ATTENDEE` property and leaves delivery to the - server. Calendula sends nothing itself (no INTERNET), so what actually reaches a - guest depends entirely on the sync adapter. Settle and document the honest - promise (and any "your account may email this guest" copy) up front. +- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable + calendars.** Calendula has no INTERNET and never sends an invitation itself; it + only writes `Attendees` rows. Whether a guest is notified is decided downstream: + local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines + and the *server* decides iMIP delivery; Google's sync adapter pushes the change + and Google decides (third-party attendee writes are historically unreliable + there). Editing is allowed on **any writable calendar** — not gated to local. + - **Honest, backend-aware copy is mandatory** (this is the whole point of the + decision): on a synced calendar show "Calendula doesn't send invitations — + your calendar account may email guests when it syncs"; on a local calendar + show "Stored on this device. No one is notified." + - Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling + state to coax a send — it writes the guest list faithfully and leaves + scheduling entirely to the backend. + - The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`, + still no INTERNET) was considered and **deferred** — revisit only if users ask + to notify guests explicitly. - **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups, resource booking — all carry server round-trips or richer sync semantics. diff --git a/.planning/STATE.md b/.planning/STATE.md index cbae6fa..a2c9e67 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -167,9 +167,10 @@ release-cut. detail screen since v0.6; the gap is the write side — make guests editable in `EventEditScreen` / `EventForm` (add by typed email, role, remove), persisted by diffing `CalendarContract.Attendees`. No new permission for the - typed-email path. **Resolve first:** the backend-specific invitation/sync - behavior (Google auto-mails invites, CalDAV writes `ATTENDEE`) — decide and - document the honest promise. Full sketch in `ROADMAP.md` → "Attendee editing". + typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all + writable calendars** — Calendula never sends (no INTERNET); honest backend-aware + copy ("your account may email guests when it syncs" on synced calendars, "no one + is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing". 3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week numbers in month, full-text search, accessibility pass). Drag-and-drop From b0f34ff18a019851104ff08affd5d4fc27461a79 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:42:37 +0200 Subject: [PATCH 08/19] feat(edit): make event attendees editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attendees were read-only (shown on the detail screen since v0.6) but the form couldn't write them — the last read-only gap in the event model. Add a Guests section to the create/edit form: - Inline grouped list: each guest is a tonal card (avatar, name/email, a tappable Required/Optional role chip, remove); the trailing card is an inline email field — type an address, press Done, it commits. No dialog, matching the app's inline-field input idiom. Manual adds are email-only (names come from sync); the InlineTextField gains IME-action support. - Reminders restyled to the same grouped-list pattern (shared GroupedItemCard / AddActionCard), replacing the single-card blob. Persistence (CalendarDataSource): new guests are written as plain RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer. On edit, a dirty-checked reconcileAttendees diffs by email: drops removed guests, inserts new ones, updates only the required/optional flag on kept rows (preserving response status). Organizer, resources and no-email rows are never touched. toEditForm carries only editable guests, so attendees now ride in the edit snapshot and an external guest change trips the conflict check. Per the settled invitation decision: Calendula has no INTERNET and never sends an invitation — it only writes the rows; the backend decides delivery. The section shows honest, calendar-aware copy ("your account may email guests when it syncs" on synced calendars, "no one is notified" on local). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 141 ++++++++ .../calendula/domain/EventForm.kt | 48 ++- .../calendula/ui/common/InlineTextField.kt | 9 + .../calendula/ui/edit/EventEditScreen.kt | 325 +++++++++++++++--- .../calendula/ui/edit/EventEditViewModel.kt | 28 ++ .../calendula/ui/settings/SettingsScreen.kt | 1 + app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../calendula/domain/EventFormTest.kt | 49 ++- 9 files changed, 557 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 44c25c3..2d958e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm @@ -536,6 +537,10 @@ class AndroidCalendarDataSource @Inject constructor( Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") } } + // Guests are best-effort like reminders: a row that fails to attach is + // logged, not surfaced as a failed create. Calendula never sends an + // invitation — it only writes the rows; the backend decides delivery. + insertAttendees(eventId, form.attendees) return eventId } @@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor( if (updated.reminders.toSet() != original.reminders.toSet()) { reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes)) } + // Same untouched-set guard for guests: only reconcile when the form's + // attendees actually changed, so unrelated edits never disturb the + // organizer/resource rows the form doesn't model. + if (updated.attendees.toSet() != original.attendees.toSet()) { + reconcileAttendees(eventId, updated.attendees) + } } override fun updateOccurrence( @@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor( // Whether the provider copied the parent's reminder rows is its // business — reconciling against the actual rows handles both ways. reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) + reconcileAttendees(exceptionId, form.attendees) return exceptionId } @@ -720,6 +732,135 @@ class AndroidCalendarDataSource @Inject constructor( } } + /** Normalised key an attendee is matched/deduped on (case-insensitive email). */ + private fun EventAttendee.key(): String = email.trim().lowercase() + + /** Distinct guests by email; blanks dropped (we key and write on the address). */ + private fun List.distinctByEmail(): List { + val seen = HashSet() + return filter { it.email.isNotBlank() && seen.add(it.key()) } + } + + private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues = + ContentValues().apply { + put(CalendarContract.Attendees.EVENT_ID, eventId) + put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim()) + attendee.name.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) } + // A plain guest — never the organizer (we don't fabricate scheduling + // identity; the backend owns that). Invited = awaiting their reply. + put( + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.RELATIONSHIP_ATTENDEE, + ) + put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType()) + put( + CalendarContract.Attendees.ATTENDEE_STATUS, + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, + ) + } + + private fun EventAttendee.providerType(): Int = if (optional) { + CalendarContract.Attendees.TYPE_OPTIONAL + } else { + CalendarContract.Attendees.TYPE_REQUIRED + } + + /** Best-effort attendee inserts (like reminders); emails are never logged. */ + private fun insertAttendees(eventId: Long, attendees: List) { + attendees.distinctByEmail().forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + + private data class AttendeeRow( + val id: Long, + val email: String, + val relationship: Int, + val type: Int, + ) { + val key: String get() = email.trim().lowercase() + } + + private fun queryAttendeeRows(eventId: Long): List = resolver.query( + CalendarContract.Attendees.CONTENT_URI, + arrayOf( + CalendarContract.Attendees._ID, + CalendarContract.Attendees.ATTENDEE_EMAIL, + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.ATTENDEE_TYPE, + ), + CalendarContract.Attendees.EVENT_ID + " = ?", + arrayOf(eventId.toString()), + null, + )?.use { c -> + buildList { + while (c.moveToNext()) { + add( + AttendeeRow( + id = c.getLong(0), + email = c.getString(1).orEmpty(), + relationship = c.getInt(2), + type = c.getInt(3), + ), + ) + } + } + } ?: emptyList() + + /** + * Make the event's *editable* guest rows match [attendees]. Only rows we + * own — has an email, not the organizer, not a resource — are reconciled; + * everything else the backend put there is left untouched. Matched rows keep + * their response status (only the required/optional type is updated if it + * changed); dropped rows are deleted; new guests inserted. Calendula sends + * nothing — the backend decides if anyone is notified on sync. + */ + private fun reconcileAttendees(eventId: Long, attendees: List) { + val target = attendees.distinctByEmail() + val targetByKey = target.associateBy { it.key() } + val editable = queryAttendeeRows(eventId).filter { + it.email.isNotBlank() && + it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER && + it.type != CalendarContract.Attendees.TYPE_RESOURCE + } + val editableKeys = editable.map { it.key }.toSet() + editable.forEach { row -> + val want = targetByKey[row.key] + if (want == null) { + resolver.delete( + CalendarContract.Attendees.CONTENT_URI, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } else if (row.type != want.providerType()) { + resolver.update( + CalendarContract.Attendees.CONTENT_URI, + ContentValues().apply { + put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType()) + }, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } + } + target.filterNot { it.key() in editableKeys }.forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + private fun Map.toContentValues(): ContentValues = ContentValues().also { cv -> forEach { (column, value) -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3cfdc40..5acc612 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -41,6 +41,27 @@ data class EventForm( */ val colorKey: String? = null, val color: Int? = null, + /** + * Guests the user has added/kept on the event. Calendula only writes these + * `Attendees` rows — it has no INTERNET and never sends an invitation + * itself; whether a guest is notified is decided downstream by the + * calendar's backend (local: no one; CalDAV/Google: the server/account). + * Read-only rows the form doesn't model (the organizer, resources) are + * preserved by the data layer, not carried here. + */ + val attendees: List = emptyList(), +) + +/** + * One editable guest: the user controls the [email] (the identity we dedup and + * write on), an optional display [name], and whether they're [optional] rather + * than required. Response status and the organizer/resource distinction are not + * user-editable, so they aren't modelled here. + */ +data class EventAttendee( + val email: String, + val name: String = "", + val optional: Boolean = false, ) /** @@ -55,6 +76,7 @@ enum class EventFormField { Availability, Visibility, Color, + Attendees, } enum class EventFormProblem { @@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): // calendar's colour. colorKey = eventColorKey, color = eventColor, + // Only editable guests ride in the form: drop the organizer and + // resource rows (not user-editable) and any without an email (we key + // edits and dedup on the address). The data layer preserves the rows we + // don't carry here, so they're never clobbered on save. + attendees = attendees + .filter { + it.relationship != AttendeeRelationship.Organizer && + it.type != AttendeeType.Resource + } + .mapNotNull { a -> + a.email?.takeIf { it.isNotBlank() }?.let { email -> + EventAttendee( + email = email, + name = a.name, + optional = a.type == AttendeeType.Optional, + ) + } + }, ) } @@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): * while the form was open. The raw row times ride along because * [toEditForm] derives the form's times from the *tapped occurrence*, so * re-deriving with the same occurrence would mask an externally moved - * event. Not covered (the form can't write them, and the dirty-checked - * write can't clobber them): attendees, status, the user's own response, - * reminder methods, and a recurring event's duration. + * event. Guests are covered (the form writes editable attendees), so an + * external attendee change now also trips the conflict check. Still not + * covered: status, the user's own response, reminder methods, the + * organizer/resource rows, and a recurring event's duration. */ data class EditSnapshot( val form: EventForm, @@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set = buildSet { if (availability != Availability.Busy) add(EventFormField.Availability) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (colorKey != null || color != null) add(EventFormField.Color) + if (attendees.isNotEmpty()) add(EventFormField.Attendees) } fun EventForm.problems(): Set = buildSet { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt index 85105e0..6a541f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -36,6 +38,9 @@ fun InlineTextField( minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.None, + imeAction: ImeAction = ImeAction.Default, + /** Invoked when the IME action key (e.g. Done) is pressed. */ + onImeAction: (() -> Unit)? = null, ) { val resolvedStyle = textStyle.copy( color = if (textStyle.color.isSpecified) { @@ -53,7 +58,11 @@ fun InlineTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType, capitalization = capitalization, + imeAction = imeAction, ), + keyboardActions = onImeAction?.let { action -> + KeyboardActions(onAny = { action() }) + } ?: KeyboardActions.Default, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> Box { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f9866e..a762c2b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.edit import android.Manifest import android.content.pm.PackageManager +import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes @@ -42,6 +42,9 @@ import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat @@ -49,11 +52,11 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -82,13 +85,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -97,6 +104,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -115,6 +123,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -587,33 +597,24 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // Reminders stack one per row (remove on the right edge); the - // add chip closes the card at the bottom. The card icon centres - // on the first row, whatever that is. - EditCard( - icon = Icons.Default.Notifications, - iconContentDescription = stringResource(R.string.event_detail_reminders), - header = { - when (val first = form.reminders.firstOrNull()) { - null -> AddReminderChip(onClick = { showReminderPicker = true }) - else -> ReminderRow( - label = reminderLabel(first), - onRemove = { viewModel.removeReminder(first) }, - ) - } - }, - ) { - form.reminders.drop(1).forEach { minutes -> - ReminderRow( - label = reminderLabel(minutes), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - if (form.reminders.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - AddReminderChip(onClick = { showReminderPicker = true }) - } + // A grouped list like guests: each reminder is its own tonal card + // with connected corners, the last row being "Add reminder". + val reminders = form.reminders + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { viewModel.removeReminder(minutes) }, + ) } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = { showReminderPicker = true }, + ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { @@ -776,6 +777,42 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Guests are an inline grouped list: each is a tonal card with a + // role toggle + remove, and the trailing card is an inline email + // field — type an address, press Done, it commits. Below sits an + // honest note — Calendula never sends invitations, so whether a + // guest is notified is the calendar backend's call. + val guests = form.attendees + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -840,6 +877,7 @@ private fun EventEditContent( ) } + if (showRecurrencePicker) { RecurrencePickerDialog( current = form.rrule, @@ -1262,45 +1300,222 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ -@Composable -private fun ReminderRow(label: String, onRemove: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), +/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ +private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = + when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape( + topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, ) - IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape( + topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, + ) + } + +/** The 2dp gap that visually separates grouped cards (none after the last). */ +private fun groupedGap(position: Position): Modifier = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier +} + +/** + * One entry in a grouped form list (a guest, a reminder): a leading [icon], + * a [title] with optional [supporting] line, and a remove button — all on a + * tonal card whose corners come from its [position] in the run. + */ +@Composable +private fun GroupedItemCard( + position: Position, + icon: ImageVector, + title: String, + removeContentDescription: String, + onRemove: () -> Unit, + supporting: String? = null, + titleMaxLines: Int = Int.MAX_VALUE, + trailing: @Composable (() -> Unit)? = null, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_remove_reminder), - modifier = Modifier.size(18.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + trailing() + } + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = removeContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +/** + * One guest as a grouped-list card: avatar glyph, name/email, a tappable + * Required/Optional role chip, and remove. The email shows as the supporting + * line when a name is present, otherwise as the title. + */ +@Composable +private fun GuestCard( + attendee: EventAttendee, + position: Position, + onToggleOptional: (Boolean) -> Unit, + onRemove: () -> Unit, +) { + val hasName = attendee.name.isNotBlank() + GroupedItemCard( + position = position, + icon = Icons.Default.Person, + title = if (hasName) attendee.name else attendee.email, + supporting = attendee.email.takeIf { hasName }, + titleMaxLines = 1, + removeContentDescription = stringResource(R.string.event_edit_remove_guest), + onRemove = onRemove, + trailing = { + FilterChip( + selected = attendee.optional, + onClick = { onToggleOptional(!attendee.optional) }, + label = { + Text( + stringResource( + if (attendee.optional) { + R.string.event_edit_attendee_optional + } else { + R.string.event_edit_attendee_required + }, + ), + ) + }, + ) + }, + ) +} + +/** The trailing "Add …" action card of a grouped form list (opens a picker). */ +@Composable +private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, ) } } } +/** + * The trailing card of the guests list: an inline email field. Type an address + * and press Done (or tap the add button) to commit it as a guest; the field + * clears, ready for the next. Matches the form's inline-field input style — no + * dialog. + */ @Composable -private fun AddReminderChip(onClick: () -> Unit) { - AssistChip( - onClick = onClick, - label = { Text(stringResource(R.string.event_edit_add)) }, - leadingIcon = { +private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { + var text by rememberSaveable { mutableStateOf("") } + val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } + fun commit() { + if (valid) { + onAdd(text.trim()) + text = "" + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.PersonAdd, contentDescription = null, - modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - }, - border = null, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(Modifier.width(16.dp)) + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.event_edit_add_guest_hint), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + onImeAction = { commit() }, + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) + if (valid) { + IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.event_edit_add_guest), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } } + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description @@ -1309,6 +1524,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } private fun fieldIcon(field: EventFormField): ImageVector = when (field) { @@ -1319,6 +1535,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index d30ece4..e878330 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EditSnapshot +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor( update { it.copy(reminders = it.reminders - minutes) } } + /** + * Add a guest, keyed on a trimmed lower-cased email so re-adding the same + * address just refreshes it (no duplicate row). A blank email is ignored — + * the email is what we write and dedup on. + */ + fun addAttendee(email: String, name: String = "", optional: Boolean = false) { + val trimmed = email.trim() + if (trimmed.isEmpty()) return + update { form -> + val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional) + val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) } + form.copy(attendees = rest + attendee) + } + } + + fun removeAttendee(attendee: EventAttendee) = + update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) } + + /** Flip a guest between required and optional (the per-row role toggle). */ + fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form -> + form.copy( + attendees = form.attendees.map { a -> + if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a + }, + ) + } + /** Moving the start drags the end along, preserving the duration. */ fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) } 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 4d1a06f..ced7652 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 @@ -872,6 +872,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } @Composable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ec4c11..af76e2e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -77,6 +77,14 @@ Hinzufügen Erinnerung hinzufügen Erinnerung entfernen + Gäste + Gast hinzufügen + Gast per E-Mail hinzufügen… + Gast entfernen + Erforderlich + Optional + Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail. + Auf diesem Gerät gespeichert. Niemand wird benachrichtigt. Benutzerdefiniert Minuten Stunden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..301596b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,14 @@ Add Add reminder Remove reminder + Guests + Add guest + Add a guest by email… + Remove guest + Required + Optional + Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs. + Stored on this device. No one is notified. Custom minutes hours diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index c6a9066..fc975c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -211,12 +211,55 @@ class EventFormTest { } @Test - fun `changes the form cannot write do not fake a conflict`() { + fun `a reminder-method change the form cannot write does not fake a conflict`() { + // The form carries reminder minutes only, not the method — so a method + // flip the form can't express must not read as an external change. + val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert))) + .toEditSnapshot(0L, 3_600_000L, berlin) + val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email))) + .toEditSnapshot(0L, 3_600_000L, berlin) + assertThat(fresh).isEqualTo(loaded) + } + + @Test + fun `an external guest change now trips the conflict check`() { + // Attendees are writable now, so they ride in the snapshot: adding a + // guest externally is a real conflict, not invisible. val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin) val fresh = detail( - attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)), + attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)), ).toEditSnapshot(0L, 3_600_000L, berlin) - assertThat(fresh).isEqualTo(loaded) + assertThat(fresh).isNotEqualTo(loaded) + } + + @Test + fun `toEditForm carries editable guests with name email and optional role`() { + val prefilled = detail( + attendees = listOf( + Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required), + Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + EventAttendee(email = "bob@example.org", name = "", optional = true), + ).inOrder() + assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees) + } + + @Test + fun `toEditForm drops the organizer, resources and guests without an email`() { + val prefilled = detail( + attendees = listOf( + Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer), + Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource), + Attendee("No address", null, AttendeeStatus.NeedsAction), + Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + ) } @Test From ac17343747c58c23c4e932a3ff8a5b3b063b2b31 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:44:51 +0200 Subject: [PATCH 09/19] docs(roadmap): attendee write-side shipped; contact picker is the name path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the attendee-editing write side shipped (b0f34ff) and record the plan to close the name-on-manual-add gap: a "from contacts" entry via ACTION_PICK on the contacts Email URI, querying the picked row for Email.ADDRESS + DISPLAY_NAME — no READ_CONTACTS (the result Intent grants temporary read access), same no-permission mechanism as the location address picker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 225368d..983411d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -480,16 +480,25 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add there. -- **Write side (new):** add `attendees` to `EventForm` and an attendees section - to `EventEditScreen` — add a guest by typed email (+ optional display name), - remove rows, set role (required / optional). Persist by diffing against the - provider's `CalendarContract.Attendees` rows on insert + dirty-checked update, - mirroring the reminders-diff (kept rows keep their fields). Organizer/self rows - are not user-editable. -- **No new permission for the typed-email path** — writing `Attendees` needs only - the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, - one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- - picker mechanism above. +- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):** + `attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an + inline grouped list — each guest a tonal card (avatar, name/email, tappable + Required/Optional role chip, remove), the trailing card an inline email field + (type → Done commits). Persisted by `reconcileAttendees` diffing the provider's + `CalendarContract.Attendees` rows on insert + dirty-checked update (new guests = + `RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the + required/optional type updates; organizer/resource/no-email rows untouched). + Reminders restyled to the same grouped-list pattern in passing. Needs only the + existing `WRITE_CALENDAR` — no new permission. +- **Name on manual add — solved next via a contact picker.** The inline field is + email-only (the quick path); names otherwise come from sync. The planned fix is + a **"from contacts" entry**: `ACTION_PICK` on + `ContactsContract.CommonDataKinds.Email.CONTENT_URI` → the system Contacts app + returns a URI to the picked email row, queried for `Email.ADDRESS` **and** the + contact `DISPLAY_NAME`, so a picked guest gets both email and name directly. The + result Intent grants temporary read access, so **no `READ_CONTACTS` permission** + — one-shot and user-driven, same no-permission mechanism as the location address + picker above. Self/organizer rows stay non-editable. - **Invitation behavior — DECIDED 2026-06-22: record-only, all writable calendars.** Calendula has no INTERNET and never sends an invitation itself; it only writes `Attendees` rows. Whether a guest is notified is decided downstream: From a4e0ec3fde805415e020275b03c982c445d5faf7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:50:23 +0200 Subject: [PATCH 10/19] feat(edit): add guests from the contact picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the name-on-manual-add gap. The add-guest row gains a "from contacts" button that launches the system contact picker (ACTION_PICK on ContactsContract Email URI); the picked email row is queried for Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name in one tap. No READ_CONTACTS — the result Intent grants temporary read access to just that row, so Calendula stays no-permission/no-network. The inline email field remains the quick email-only path. Mark the roadmap item shipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 18 ++--- .../calendula/ui/edit/EventEditScreen.kt | 73 +++++++++++++++++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 983411d..e090596 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -490,15 +490,15 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. required/optional type updates; organizer/resource/no-email rows untouched). Reminders restyled to the same grouped-list pattern in passing. Needs only the existing `WRITE_CALENDAR` — no new permission. -- **Name on manual add — solved next via a contact picker.** The inline field is - email-only (the quick path); names otherwise come from sync. The planned fix is - a **"from contacts" entry**: `ACTION_PICK` on - `ContactsContract.CommonDataKinds.Email.CONTENT_URI` → the system Contacts app - returns a URI to the picked email row, queried for `Email.ADDRESS` **and** the - contact `DISPLAY_NAME`, so a picked guest gets both email and name directly. The - result Intent grants temporary read access, so **no `READ_CONTACTS` permission** - — one-shot and user-driven, same no-permission mechanism as the location address - picker above. Self/organizer rows stay non-editable. +- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline + field is email-only (the quick path); the add row also has a **"from contacts" + button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` → + the system Contacts app returns a URI to the picked email row, queried for + `Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both + email and name in one tap. The result Intent grants temporary read access, so + **no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission + mechanism as the location address picker above. Self/organizer rows stay + non-editable. - **Invitation behavior — DECIDED 2026-06-22: record-only, all writable calendars.** Calendula has no INTERNET and never sends an invitation itself; it only writes `Attendees` rows. Whether a guest is notified is decided downstream: diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index a762c2b..1e9416d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -1,7 +1,12 @@ package de.jeanlucmakiola.calendula.ui.edit import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -38,6 +43,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications @@ -439,6 +445,22 @@ private fun EventEditContent( var showColorPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } + // Pick a guest from contacts: a one-shot system picker returning the chosen + // email row (email + display name). No READ_CONTACTS — the result Intent + // grants temporary read access, so Calendula stays no-network/no-permission. + val context = LocalContext.current + val pickContact = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + readContactEmail(context, uri)?.let { (email, name) -> + viewModel.addAttendee(email, name) + } + } + } + } + val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } // The accent ties the form to the detail screen's design language: the // bar under the title takes the target calendar's colour. @@ -797,6 +819,16 @@ private fun EventEditContent( AddGuestInlineCard( position = positionOf(guests.size, rows), onAdd = { viewModel.addAttendee(it) }, + onPickContact = { + runCatching { + pickContact.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + ), + ) + } + }, ) Spacer(Modifier.height(8.dp)) Text( @@ -1457,13 +1489,17 @@ private fun AddActionCard(label: String, position: Position, onClick: () -> Unit } /** - * The trailing card of the guests list: an inline email field. Type an address - * and press Done (or tap the add button) to commit it as a guest; the field - * clears, ready for the next. Matches the form's inline-field input style — no - * dialog. + * The trailing card of the guests list: an inline email field plus a "from + * contacts" button. Type an address and press Done (or tap +) to commit a guest; + * or pick from contacts to fill email + name at once. The field clears after, + * ready for the next. Matches the form's inline-field input style — no dialog. */ @Composable -private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { +private fun AddGuestInlineCard( + position: Position, + onAdd: (String) -> Unit, + onPickContact: () -> Unit, +) { var text by rememberSaveable { mutableStateOf("") } val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } fun commit() { @@ -1511,10 +1547,37 @@ private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { ) } } + IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } } } } +/** + * Read the email + display name from a contact-pick result URI. No + * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. + */ +private fun readContactEmail(context: Context, uri: Uri): Pair? = + context.contentResolver.query( + uri, + arrayOf( + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.Contacts.DISPLAY_NAME, + ), + null, null, null, + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val email = cursor.getString(0).orEmpty().trim() + val name = cursor.getString(1).orEmpty().trim() + email.takeIf { it.isNotEmpty() }?.let { it to name } + } + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index af76e2e..712bab9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -80,6 +80,7 @@ Gäste Gast hinzufügen Gast per E-Mail hinzufügen… + Aus Kontakten hinzufügen Gast entfernen Erforderlich Optional diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 301596b..3ee560c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Guests Add guest Add a guest by email… + Add from contacts Remove guest Required Optional From 9839f9cd38f51751c87137650b01279d18df47f5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 09:22:51 +0200 Subject: [PATCH 11/19] feat(edit): pick a location from the contact picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the attendee contact-picker mechanism for the location field: a Contacts button beside the location input opens the system picker scoped to postal-address rows, and the chosen contact's formatted address is dropped into the field (multi-line addresses collapsed to one line). Same no-permission guarantee as the guest picker — ACTION_PICK grants temporary read access, so no READ_CONTACTS is required. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/edit/EventEditScreen.kt | 74 +++++++++++++++++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 1e9416d..88fb610 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -461,6 +461,21 @@ private fun EventEditContent( } } + // Pick a postal address from contacts: the same one-shot picker, scoped to + // address rows, dropping the chosen contact's formatted address into the + // location field. Same no-permission guarantee as the guest picker above. + val pickContactAddress = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + readContactAddress(context, uri)?.let { address -> + viewModel.setLocation(address) + } + } + } + } + val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } // The accent ties the form to the detail screen's design language: the // bar under the title takes the target calendar's colour. @@ -592,11 +607,39 @@ private fun EventEditContent( icon = Icons.Default.Place, iconContentDescription = stringResource(R.string.event_detail_location), ) { - InlineField( - value = form.location, - onValueChange = viewModel::setLocation, - placeholder = stringResource(R.string.event_detail_location), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + InlineField( + value = form.location, + onValueChange = viewModel::setLocation, + placeholder = stringResource(R.string.event_detail_location), + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp), + ) + IconButton( + onClick = { + runCatching { + pickContactAddress.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.StructuredPostal + .CONTENT_URI, + ), + ) + } + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource( + R.string.event_edit_location_from_contacts, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } } } @@ -1578,6 +1621,27 @@ private fun readContactEmail(context: Context, uri: Uri): Pair? email.takeIf { it.isNotEmpty() }?.let { it to name } } +/** + * Read the formatted postal address from a contact-pick result URI. No + * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. + * The provider's formatted address is multi-line; collapse it to one line so + * it sits cleanly in the single-line location field. + */ +private fun readContactAddress(context: Context, uri: Uri): String? = + context.contentResolver.query( + uri, + arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS), + null, null, null, + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + cursor.getString(0).orEmpty() + .lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(", ") + .takeIf { it.isNotEmpty() } + } + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 037c717..d3a43d2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -81,6 +81,7 @@ Gast hinzufügen Gast per E-Mail hinzufügen… Aus Kontakten hinzufügen + Adresse aus Kontakten wählen Gast entfernen Erforderlich Optional diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21e31be..434de7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,7 @@ Add guest Add a guest by email… Add from contacts + Pick address from contacts Remove guest Required Optional From 7f8a9069c04505ec5b7296e26d31f13c98e929b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 09:36:40 +0200 Subject: [PATCH 12/19] feat(views): show a current-time line in day and week views A thin primary-coloured indicator (leading dot + line) marks the current time across today's column in the day and week timelines, positioned on the same HOUR_HEIGHT scale as the event blocks so it lines up with the grid. Shared ui/common/NowLine.kt ticks once a minute, re-aligning to each minute boundary to avoid drift, and only mounts on today's column so a single coroutine runs. Renders nothing when today isn't in view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/NowLine.kt | 90 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 7 ++ .../calendula/ui/week/WeekScreen.kt | 7 ++ 3 files changed, 104 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt new file mode 100644 index 0000000..faa243f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt @@ -0,0 +1,90 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant + +private val DotSize = 8.dp +private val LineThickness = 2.dp + +/** + * Current wall-clock instant that updates once a minute, re-aligning to each + * minute boundary (rather than ticking a fixed 60 s) so it never drifts toward + * the middle of a minute. Drives the "now" line in the day/week grids. + */ +@Composable +private fun rememberCurrentMinute(): State { + val instant = remember { mutableStateOf(Clock.System.now()) } + LaunchedEffect(Unit) { + while (true) { + val now = Clock.System.now() + instant.value = now + delay(60_000L - now.toEpochMilliseconds() % 60_000L) + } + } + return instant +} + +/** + * A thin "current time" indicator — a leading dot plus a line — drawn across a + * day column. Positioned on the same [hourHeight] scale the event blocks use so + * it lines up with the grid, and refreshed each minute. Renders nothing unless + * the wall clock is on [date]; callers mount it only for the column showing + * today, so the per-minute tick runs on a single column. + */ +@Composable +fun NowLine( + date: LocalDate, + hourHeight: Dp, + modifier: Modifier = Modifier, +) { + val now by rememberCurrentMinute() + val local = now.toLocalDateTime(TimeZone.currentSystemDefault()) + if (local.date != date) return + + val minutes = local.hour * 60 + local.minute + val top = hourHeight * (minutes / 60f) + val color = MaterialTheme.colorScheme.primary + Box( + modifier = modifier + .fillMaxWidth() + .height(DotSize) + .offset(y = top - DotSize / 2), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(LineThickness) + .background(color), + ) + // The dot anchors the line to the gutter edge, mirroring the standard + // calendar "now" marker; drawn after the line so it sits on top. + Box( + modifier = Modifier + .size(DotSize) + .background(color, CircleShape), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 9e7ef84..6b98251 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -71,6 +71,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.next @@ -496,6 +497,7 @@ private fun Timeline( blocks = state.timed, dark = dark, date = state.date, + today = state.today, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -512,6 +514,7 @@ private fun DayColumnCard( blocks: List, dark: Boolean, date: LocalDate, + today: LocalDate, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, @@ -556,6 +559,10 @@ private fun DayColumnCard( .padding(horizontal = 1.dp), ) } + // Current-time line, on top of the events, only on today's column. + if (date == today) { + NowLine(date = date, hourHeight = HOUR_HEIGHT) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 3e3660f..0fbfc6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -76,6 +76,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -610,6 +611,7 @@ private fun Timeline( blocks = state.timedByDay[day].orEmpty(), dark = dark, date = day, + today = state.today, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -628,6 +630,7 @@ private fun DayColumnCard( blocks: List, dark: Boolean, date: LocalDate, + today: LocalDate, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, @@ -671,6 +674,10 @@ private fun DayColumnCard( .padding(horizontal = 1.dp), ) } + // Current-time line, on top of the events, only on today's column. + if (date == today) { + NowLine(date = date, hourHeight = HOUR_HEIGHT) + } } } } From 572a4734eaee84dca66c512b19b5b9f407eae7be Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 09:52:24 +0200 Subject: [PATCH 13/19] feat(qs): add a 'New event' Quick Settings tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stateless TileService that opens the create-event form on today — the same action as the launcher 'New event' shortcut and the agenda widget's +. Reuses MainActivity.openCreateIntent; wrapped in unlockAndRun and using the API 34+ startActivityAndCollapse(PendingIntent) form (deprecated Intent form below 34). No new permission (BIND_QUICK_SETTINGS_TILE is system-side). Discoverability: Settings → New event form gains an 'Add Quick Settings tile' row that fires StatusBarManager.requestAddTileService (API 33+); on older versions the tile is still addable manually from the system QS editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 14 +++++++ .../calendula/qs/NewEventTileService.kt | 41 +++++++++++++++++++ .../calendula/ui/settings/SettingsScreen.kt | 35 ++++++++++++++++ app/src/main/res/drawable/ic_qs_new_event.xml | 18 ++++++++ app/src/main/res/values-de/strings.xml | 5 +++ app/src/main/res/values/strings.xml | 5 +++ 6 files changed, 118 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt create mode 100644 app/src/main/res/drawable/ic_qs_new_event.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20737d8..d91eb70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,20 @@ android:excludeFromRecents="true" android:launchMode="singleTask" /> + + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt new file mode 100644 index 0000000..4199341 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.qs + +import android.app.PendingIntent +import android.os.Build +import android.service.quicksettings.TileService +import de.jeanlucmakiola.calendula.MainActivity +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +/** + * Quick Settings tile: tapping it opens the create-event form on today — the + * same action as the launcher "New event" shortcut and the agenda widget's "+". + * A stateless action tile, so there is no on/off state to keep in sync. + */ +class NewEventTileService : TileService() { + + override fun onClick() { + super.onClick() + val today = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()).date + val intent = MainActivity.openCreateIntent(this, today) + // Launch only once the device is unlocked: creating an event behind the + // keyguard makes no sense, and the shade can't start an activity over a + // locked screen anyway. + unlockAndRun { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pending = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + startActivityAndCollapse(pending) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + } +} 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 604ac85..47e86ba 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,12 +1,16 @@ package de.jeanlucmakiola.calendula.ui.settings import android.Manifest +import android.app.StatusBarManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.drawable.Icon import android.os.Build import android.os.PowerManager import android.provider.Settings +import androidx.annotation.RequiresApi import android.text.format.DateFormat import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -80,6 +84,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.qs.NewEventTileService import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport @@ -514,9 +519,39 @@ private fun EventFormScreen( ) }, ) + + // One-tap add of the "New event" Quick Settings tile. The system prompt + // is API 33+; on older versions the tile is still addable manually from + // the QS editor, so the row simply doesn't appear there. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Spacer(Modifier.height(24.dp)) + val context = LocalContext.current + GroupedRow( + title = stringResource(R.string.settings_qs_tile), + summary = stringResource(R.string.settings_qs_tile_hint), + position = Position.Alone, + onClick = { requestAddQsTile(context) }, + ) + } } } +/** + * Ask the system to add the "New event" Quick Settings tile (API 33+). The OS + * shows its own confirmation dialog and handles the already-added case, so no + * result handling is needed here. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun requestAddQsTile(context: Context) { + val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return + statusBar.requestAddTileService( + ComponentName(context, NewEventTileService::class.java), + context.getString(R.string.qs_tile_new_event_label), + Icon.createWithResource(context, R.drawable.ic_qs_new_event), + context.mainExecutor, + ) { /* result code unused — the system surfaces its own feedback */ } +} + /** * Reminder-notifications toggle (v1.4), mirroring the onboarding step. * Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) — diff --git a/app/src/main/res/drawable/ic_qs_new_event.xml b/app/src/main/res/drawable/ic_qs_new_event.xml new file mode 100644 index 0000000..df0f3fb --- /dev/null +++ b/app/src/main/res/drawable/ic_qs_new_event.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 037c717..af81d6d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -247,6 +247,11 @@ Neuer Termin Neuen Termin erstellen + + Neuer Termin + Schnelleinstellungen-Kachel hinzufügen + Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen. + Kalender diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21e31be..0a412c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -361,6 +361,11 @@ New event Create a new event + + New event + Add Quick Settings tile + Add a “New event” tile to the Quick Settings panel. + https://gitea.jeanlucmakiola.de/makiolaj/calendula https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE From b8a7191fbe4a9023597b812aa1ab610896c22923 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 09:59:44 +0200 Subject: [PATCH 14/19] fix(nav): bring external new-event/open-date to the front The create form and Settings/calendar-manager are sibling overlays in one Box, with Settings drawn above the form. An external 'new event' request (QS tile, launcher shortcut, widget) set the create state correctly but rendered the form underneath an open Settings, forcing the user to back out first. Dismiss the covering overlays (Settings, calendar manager, detail/edit, import) when handling a Create or OpenDate nav request, so the requested destination is revealed on top. Fixes the same latent bug for the shortcut and widget. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 941f80f..9629464 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -137,16 +137,37 @@ fun CalendarHost( } } + // Close every overlay that can sit over the calendar, so an externally + // requested destination (a widget/shortcut/QS-tile launch) is revealed on + // top instead of underneath whatever the user had open. + fun dismissCoveringOverlays() { + showSettings = false + showCalendars = false + detailKey = null + editKey = null + importUri = null + importForm = null + } + // A home-screen widget launch asks to open a date (→ day view) or start a // create. Handled once and cleared, mirroring [requestedDetailKey]. LaunchedEffect(widgetNavRequest) { when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { + // Reveal the day view: drop any overlay that would cover it, so + // an external date-open doesn't land under an open Settings/form. + dismissCoveringOverlays() + createDateIso = null pendingDayIso = req.dateIso view = CalendarView.Day onWidgetNavConsumed() } is WidgetNavRequest.Create -> { + // External "new event" entries (QS tile / launcher shortcut / + // widget) must land on top of whatever is open — the form overlay + // sits below Settings/calendars in the Box, so without this it + // would open hidden underneath them. + dismissCoveringOverlays() val iso = req.dateIso ?: Clock.System.now() .toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() heldCreateIso = iso From 5b99da32b0603c1ce4e32517c3965abb2efea49a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 10:13:33 +0200 Subject: [PATCH 15/19] fix(qs): silence StartActivityAndCollapseDeprecated lint error lintDebug aborts on the deprecated startActivityAndCollapse(Intent) overload in the pre-34 branch, even though that overload is the only one available below UpsideDownCake and is reached only there. Suppress the lint issue (and the compiler deprecation) at the function level since the call is intentional and version-gated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../de/jeanlucmakiola/calendula/qs/NewEventTileService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt index 4199341..757aef6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt @@ -15,6 +15,9 @@ import kotlin.time.Clock */ class NewEventTileService : TileService() { + // The pre-34 branch intentionally uses the deprecated Intent overload: it is + // the only form available below UpsideDownCake, and is reached only there. + @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated") override fun onClick() { super.onClick() val today = Clock.System.now() @@ -33,7 +36,6 @@ class NewEventTileService : TileService() { ) startActivityAndCollapse(pending) } else { - @Suppress("DEPRECATION") startActivityAndCollapse(intent) } } From 82e93fda4c963e15b9806967cc6e51eb9ce6ad1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 10:42:37 +0200 Subject: [PATCH 16/19] feat(search): full-text event search from the top bar A magnifier in each calendar screen's top bar opens a search overlay: type a query and matching events (title, location or description) appear, nearest-to- today first; tapping a result opens its detail. - Data: query the Events table directly with a LIKE selection on title / description / location (wildcards escaped), so search is unbounded in time and filtered provider-side. New SearchProjection + toSearchResult mapper reuse the .ics export's DURATION handling for recurring masters. Hidden calendars are filtered out, mirroring instances(). - SearchViewModel debounces the query (250 ms), needs >= 2 chars, and orders results upcoming-ascending then past-descending. - SearchScreen: autofocused inline field in the top bar, GroupedRow results reusing the agenda row style, idle/empty states. Hosted as a CalendarHost overlay below detail/edit so a tapped result's detail draws on top. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 34 +++ .../data/calendar/CalendarRepository.kt | 7 + .../data/calendar/CalendarRepositoryImpl.kt | 7 + .../calendula/data/calendar/Projections.kt | 32 +++ .../calendula/data/calendar/SearchMapper.kt | 40 +++ .../calendula/ui/CalendarHost.kt | 24 ++ .../calendula/ui/agenda/AgendaScreen.kt | 10 + .../calendula/ui/day/DayScreen.kt | 10 + .../calendula/ui/month/MonthScreen.kt | 10 + .../calendula/ui/search/SearchScreen.kt | 244 ++++++++++++++++++ .../calendula/ui/search/SearchViewModel.kt | 77 ++++++ .../calendula/ui/week/WeekScreen.kt | 10 + app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../data/calendar/FakeCalendarDataSource.kt | 2 + 15 files changed, 523 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 2d958e9..b93b5f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -47,6 +47,14 @@ interface CalendarDataSource { fun instances(beginMillis: Long, endMillis: Long): List fun eventDetail(eventId: Long): EventDetail? + /** + * Master/one-off events whose title, description or location contains + * [query] (case-insensitive), across all calendars, newest first. Reads the + * Events table directly so the search is unbounded in time; exception rows + * are excluded (see [SearchProjection]). [query] is assumed non-blank. + */ + fun searchEvents(query: String): List + /** * The event-colour palette the calendar's account publishes * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the @@ -298,6 +306,32 @@ class AndroidCalendarDataSource @Inject constructor( )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() } + override fun searchEvents(query: String): List { + ensureObserversRegistered() + val trimmed = query.trim() + if (trimmed.isEmpty()) return emptyList() + // Escape the SQL LIKE wildcards so a literal % or _ in the query matches + // itself instead of acting as a wildcard. + val escaped = trimmed + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + val like = "%$escaped%" + val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + + "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" + val selection = "($match) AND " + + "${CalendarContract.Events.DELETED} = 0 AND " + + "${CalendarContract.Events.ORIGINAL_ID} IS NULL" + return resolver.query( + CalendarContract.Events.CONTENT_URI, + SearchProjection.COLUMNS, + selection, + arrayOf(like, like, like), + CalendarContract.Events.DTSTART + " DESC", + )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toSearchResult() } } ?: emptyList() + } + override fun eventDetail(eventId: Long): EventDetail? { val attendees = queryAttendees(eventId) val reminders = queryReminders(eventId) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index 4dcaac1..27c20ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -16,6 +16,13 @@ interface CalendarRepository { fun instances(range: ClosedRange): Flow> suspend fun eventDetail(eventId: Long): EventDetail + /** + * Events whose title, description or location contains [query], with hidden + * calendars removed and newest first. Empty when [query] is blank. Searches + * the whole history/future (see [CalendarDataSource.searchEvents]). + */ + suspend fun searchEvents(query: String): List + /** * The event-colour palette a calendar's account publishes; empty when it * exposes none (see [CalendarDataSource.eventColorPalette]). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 749c429..c7949ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) } + override suspend fun searchEvents(query: String): List = withContext(io) { + if (query.isBlank()) return@withContext emptyList() + val hidden = prefs.hiddenCalendarIds.first() + dataSource.searchEvents(query) + .let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } } + } + override suspend fun eventColorPalette(calendarId: Long): List = withContext(io) { dataSource.eventColorPalette(calendarId) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 0c190f7..619ec7c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -139,6 +139,38 @@ internal object EventExportProjection { const val IDX_CALENDAR_ID = 13 } +/** + * Master/one-off Events rows matched by a full-text search. Like + * [EventExportProjection] it reads the Events table directly (so the search is + * unbounded in time), carrying DURATION for recurring rows that have no DTEND. + * Colour folds the calendar fallback like [InstanceProjection]. + */ +internal object SearchProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.CALENDAR_ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.EVENT_COLOR, + CalendarContract.Events.CALENDAR_COLOR, + CalendarContract.Events.EVENT_LOCATION, + ) + + const val IDX_ID = 0 + const val IDX_CALENDAR_ID = 1 + const val IDX_TITLE = 2 + const val IDX_DTSTART = 3 + const val IDX_DTEND = 4 + const val IDX_DURATION = 5 + const val IDX_ALL_DAY = 6 + const val IDX_EVENT_COLOR = 7 + const val IDX_CALENDAR_COLOR = 8 + const val IDX_LOCATION = 9 +} + internal object AttendeeProjection { val COLUMNS: Array = arrayOf( CalendarContract.Attendees.ATTENDEE_NAME, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt new file mode 100644 index 0000000..4c09781 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis + +/** + * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the + * Instances query this reads the series master, so there is no instance id (the + * event id stands in as the list key) and recurring rows carry DURATION instead + * of DTEND — reconstruct the end the same way the `.ics` export does. + */ +internal fun ColumnReader.toSearchResult(): EventInstance? { + val dtStart = getLong(SearchProjection.IDX_DTSTART) + if (dtStart < 0L) return null + val end = when { + !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) + else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) + }.coerceAtLeast(dtStart) + + val rawTitle = getString(SearchProjection.IDX_TITLE) + val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { + getInt(SearchProjection.IDX_CALENDAR_COLOR) + } else { + getInt(SearchProjection.IDX_EVENT_COLOR) + } + val eventId = getLong(SearchProjection.IDX_ID) + + return EventInstance( + instanceId = eventId, + eventId = eventId, + calendarId = getLong(SearchProjection.IDX_CALENDAR_ID), + title = title, + start = dtStart.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0, + color = color, + location = getString(SearchProjection.IDX_LOCATION), + ) +} 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 941f80f..9ccfdd7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.imports.ImportScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen +import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen import kotlinx.datetime.LocalDate @@ -97,6 +98,12 @@ fun CalendarHost( var showSettings by rememberSaveable { mutableStateOf(false) } val onOpenSettings = { showSettings = true } + // Full-text search — its own overlay, opened from each calendar screen's + // top bar. Sits below the detail/edit overlays so tapping a result reveals + // the detail on top and backing out returns to the results. + var showSearch by rememberSaveable { mutableStateOf(false) } + val onOpenSearch = { showSearch = true } + // Calendar manager (reached from Settings) — its own overlay so it slides // over Settings and survives view switches. var showCalendars by rememberSaveable { mutableStateOf(false) } @@ -168,6 +175,7 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) CalendarView.Day -> DayScreen( @@ -175,6 +183,7 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, ) @@ -183,6 +192,7 @@ fun CalendarHost( onSelectView = onSelectView, onOpenDay = onOpenDay, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) CalendarView.Agenda -> AgendaScreen( @@ -190,10 +200,24 @@ fun CalendarHost( onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, ) } + // Search overlay — below detail/edit in the Box so a tapped result's + // detail screen draws on top, and closing it returns to the results. + AnimatedVisibility( + visible = showSearch, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + SearchScreen( + onBack = { showSearch = false }, + onEventClick = onEventClick, + ) + } + // Prefer the live key; fall back to the held one only while sliding out. val activeKey = detailKey ?: heldKey AnimatedVisibility( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 83d56a9..ef4acbb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -75,6 +76,7 @@ fun AgendaScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), @@ -119,6 +121,7 @@ fun AgendaScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -274,6 +277,7 @@ private fun AgendaTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, ) { TopAppBar( @@ -292,6 +296,12 @@ private fun AgendaTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 9e7ef84..abc41d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -105,6 +106,7 @@ fun DayScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, initialDateIso: String? = null, @@ -188,6 +190,7 @@ fun DayScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -340,6 +343,7 @@ private fun DayTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -358,6 +362,12 @@ private fun DayTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 9210063..f2d5b28 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -86,6 +87,7 @@ fun MonthScreen( onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), @@ -161,6 +163,7 @@ fun MonthScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -261,6 +264,7 @@ private fun MonthTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -279,6 +283,12 @@ private fun MonthTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt new file mode 100644 index 0000000..737ca31 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -0,0 +1,244 @@ +package de.jeanlucmakiola.calendula.ui.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.InlineTextField +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.positionOf +import java.time.Instant as JavaInstant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * Full-text event search (top-bar entry). Type a query → matching events + * (title / location / description) across the whole calendar, newest-relevant + * first; tap a result to open its detail. A full-screen overlay hosted by + * [de.jeanlucmakiola.calendula.ui.CalendarHost]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + onBack: () -> Unit, + onEventClick: (EventInstance) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val query by viewModel.query.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + // Open straight into typing — request focus and raise the keyboard once. + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboard?.show() + } + BackHandler(onBack = onBack) + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + TopAppBar( + title = { + InlineTextField( + value = query, + onValueChange = viewModel::setQuery, + placeholder = stringResource(R.string.search_hint), + imeAction = ImeAction.Search, + onImeAction = { keyboard?.hide() }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.search_back), + ) + } + }, + actions = { + if (query.isNotEmpty()) { + IconButton(onClick = { viewModel.setQuery("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + when (val s = state) { + SearchUiState.Idle -> SearchMessage( + icon = null, + text = stringResource(R.string.search_idle_hint), + ) + is SearchUiState.Empty -> SearchMessage( + icon = Icons.Default.SearchOff, + text = stringResource(R.string.search_empty, s.query), + ) + is SearchUiState.Results -> SearchResults( + events = s.events, + onEventClick = onEventClick, + ) + } + } + } +} + +@Composable +private fun SearchResults( + events: List, + onEventClick: (EventInstance) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp), + ) { + itemsIndexed( + items = events, + key = { _, event -> event.eventId }, + ) { index, event -> + SearchResultRow( + event = event, + position = positionOf(index, events.size), + onClick = { onEventClick(event) }, + ) + } + } +} + +@Composable +private fun SearchResultRow( + event: EventInstance, + position: Position, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + GroupedRow( + title = event.title, + summary = searchSummary(event), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(pastelize(event.color, dark)), + ) + }, + onClick = onClick, + ) +} + +/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */ +@Composable +private fun searchSummary(event: EventInstance): String { + val locale = currentLocale() + val zone = remember { ZoneId.systemDefault() } + val start = remember(event.start, zone) { + JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) + } + val dateText = remember(locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + }.format(start) + val timeText = if (event.isAllDay) { + stringResource(R.string.event_detail_all_day) + } else { + remember(locale) { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + }.format(start) + } + val base = "$dateText · $timeText" + return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base +} + +@Composable +private fun SearchMessage( + icon: ImageVector?, + text: String, +) { + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..7bb8ed9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.calendula.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlin.time.Clock +import javax.inject.Inject + +/** Shortest query that triggers a search; one character matches almost everything. */ +private const val MIN_QUERY_LENGTH = 2 + +sealed interface SearchUiState { + /** No query yet, or one too short to search — show the prompt. */ + data object Idle : SearchUiState + + /** A query ran but matched nothing. */ + data class Empty(val query: String) : SearchUiState + + /** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */ + data class Results(val events: List) : SearchUiState +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class SearchViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + val state: StateFlow = _query + .debounce(250L) + .map { it.trim() } + .distinctUntilChanged() + .mapLatest { q -> + if (q.length < MIN_QUERY_LENGTH) { + SearchUiState.Idle + } else { + val results = repository.searchEvents(q) + if (results.isEmpty()) SearchUiState.Empty(q) + else SearchUiState.Results(sortNearestFirst(results)) + } + } + .catch { emit(SearchUiState.Idle) } + .flowOn(io) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle) + + fun setQuery(value: String) { + _query.value = value + } + + /** Soonest upcoming (and ongoing) first, then the most recent past. */ + private fun sortNearestFirst(events: List): List { + val now = Clock.System.now() + val (upcoming, past) = events.partition { it.end >= now } + return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 3e3660f..e905ca5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue @@ -113,6 +114,7 @@ fun WeekScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), @@ -193,6 +195,7 @@ fun WeekScreen( selectedView = selectedView, onCycleView = { onSelectView(selectedView.next()) }, onOpenDrawer = { scope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, ) }, @@ -351,6 +354,7 @@ private fun WeekTopBar( selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { TopAppBar( @@ -369,6 +373,12 @@ private fun WeekTopBar( } }, actions = { + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } ViewSwitcherPill( current = selectedView, onCycle = onCycleView, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 037c717..84c3801 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -232,6 +232,14 @@ Nichts geplant Anstehende Termine erscheinen hier. + + Suchen + Termine suchen + Zurück + Löschen + Durchsuche deine Termine nach Titel, Ort oder Notizen. + Keine Termine passen zu „%1$s“. + Anstehend Calendula Agenda diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21e31be..fb14991 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,6 +233,14 @@ Nothing scheduled Upcoming events will show up here. + + Search + Search events + Back + Clear + Search your events by title, location or notes. + No events match “%1$s”. + Upcoming Calendula agenda diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index f6a447b..900c9bc 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var calendarsResult: List = emptyList() var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } + var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() @@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun calendars(): List = calendarsResult override fun instances(beginMillis: Long, endMillis: Long): List = instancesResult(beginMillis, endMillis) + override fun searchEvents(query: String): List = searchResult(query) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) From 5457a3428254ebd527c19dcede22855c3f3c5325 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 10:51:28 +0200 Subject: [PATCH 17/19] feat(search): show a recurring result's next occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recurring master's DTSTART is the series start, which for long-running series reads as an old date and sorts into the past. For recurring hits (non-empty RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider — the soonest upcoming within ~2 years, else the most recent past — and display and sort by that. Falls back to the series start when no occurrence lies in the window. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 66 ++++++++++++++++++- .../calendula/data/calendar/Projections.kt | 6 ++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index b93b5f8..8e02288 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -329,7 +329,64 @@ class AndroidCalendarDataSource @Inject constructor( selection, arrayOf(like, like, like), CalendarContract.Events.DTSTART + " DESC", - )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toSearchResult() } } ?: emptyList() + )?.use { c -> + val reader = CursorColumnReader(c) + val out = ArrayList(c.count) + while (c.moveToNext()) { + val base = reader.toSearchResult() ?: continue + // A recurring master's DTSTART is the series start; show its + // nearest occurrence instead so the date is the one the user + // actually cares about (and sorting reflects it). + val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || + !reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty() + out += if (recurring) { + nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> + base.copy( + start = begin.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + ) + } ?: base + } else { + base + } + } + out + } ?: emptyList() + } + + /** + * The occurrence of [eventId] nearest to now: the soonest upcoming one + * within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one + * within the same window behind. Null when neither exists (e.g. a series + * that only starts further out than the window) — the caller then keeps the + * series-start DTSTART. Returns (begin, end) epoch millis. + */ + private fun nearestOccurrenceMillis(eventId: Long): Pair? { + val now = System.currentTimeMillis() + return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true) + ?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false) + } + + private fun occurrenceInWindow( + eventId: Long, + beginMillis: Long, + endMillis: Long, + soonestFirst: Boolean, + ): Pair? { + val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { + ContentUris.appendId(this, beginMillis) + ContentUris.appendId(this, endMillis) + }.build() + val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC" + return resolver.query( + uri, + arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), + "${CalendarContract.Instances.EVENT_ID} = ?", + arrayOf(eventId.toString()), + order, + )?.use { c -> + if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1) + } } override fun eventDetail(eventId: Long): EventDetail? { @@ -989,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor( * together (by account) in the filter sheet and calendar manager. */ const val LOCAL_ACCOUNT_NAME = "Calendula" + + /** + * How far ahead/behind a search looks for a recurring event's nearest + * occurrence (~2 years). Wide enough for everyday series; a series that + * next fires beyond it falls back to its series-start date. + */ + const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 619ec7c..5a362bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -157,6 +157,10 @@ internal object SearchProjection { CalendarContract.Events.EVENT_COLOR, CalendarContract.Events.CALENDAR_COLOR, CalendarContract.Events.EVENT_LOCATION, + // Recurrence markers: a non-empty RRULE or RDATE means the result should + // display its nearest occurrence, not the series-start DTSTART. + CalendarContract.Events.RRULE, + CalendarContract.Events.RDATE, ) const val IDX_ID = 0 @@ -169,6 +173,8 @@ internal object SearchProjection { const val IDX_EVENT_COLOR = 7 const val IDX_CALENDAR_COLOR = 8 const val IDX_LOCATION = 9 + const val IDX_RRULE = 10 + const val IDX_RDATE = 11 } internal object AttendeeProjection { From 013efef29ea0c1f737414d98df7067d125b25629 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 11:05:26 +0200 Subject: [PATCH 18/19] fix(search): clear on reopen and centre messages above the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search ViewModel is activity-scoped and outlives the overlay, so a reopened search showed the previous query/results — reset the query when the screen re-enters (peeking a result keeps it, as the screen stays composed under the detail). Add imePadding so the idle/empty message re-centres in the area above the keyboard instead of staying centred on the full page behind it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/search/SearchScreen.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 737ca31..5309844 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -11,6 +11,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -78,8 +79,12 @@ fun SearchScreen( val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current - // Open straight into typing — request focus and raise the keyboard once. + // Each fresh open starts blank and straight into typing. The ViewModel is + // activity-scoped so it outlives the overlay; clearing on (re)enter is what + // resets a previous search. Peeking a result doesn't re-run this (the screen + // stays composed under the detail), so backing out keeps the query. LaunchedEffect(Unit) { + viewModel.setQuery("") focusRequester.requestFocus() keyboard?.show() } @@ -126,7 +131,15 @@ fun SearchScreen( ) }, ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { + // imePadding shrinks the content by the keyboard, so the centered + // idle/empty message re-centres in the space above it (and the results + // list lifts clear of the keyboard too). + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + ) { when (val s = state) { SearchUiState.Idle -> SearchMessage( icon = null, From 3d8e3ca69e9075e4ba4f657120b4667ded8e39f1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 11:42:56 +0200 Subject: [PATCH 19/19] chore(release): prepare v2.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump versionName to 2.8.0 (versionCode 20800) — the merge of this to main is what cuts the release. Move the accumulated 2.8.0 work out of [Unreleased] into a dated CHANGELOG section and regenerate the F-Droid per-version changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 33 +++++++++++++++- app/build.gradle.kts | 4 +- .../android/en-US/changelogs/20800.txt | 38 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/20800.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a51a2..8882c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.8.0] — 2026-06-23 ### Added +- Find events fast. A search button in the top bar of every calendar view opens + a search box — type a couple of letters and matching events (by title, + location or description) appear, soonest first with past events below. Tap a + result to open it. Search covers your whole calendar, not just what's on + screen, and skips calendars you've hidden. A recurring event shows its next + occurrence rather than the date the series first started. +- A current-time line in the day and week views. A thin coloured line marks the + present moment across today's column, so you can see at a glance where you are + in the day. It updates every minute and only appears when today is in view. +- Add and remove event guests. The create/edit form now has a Guests section: + add people by email or pick them from your contacts, mark each as required or + optional, and remove them. Calendula never sends invitations itself (it has no + internet access) — it only records the guests; if the event lives on a synced + account, that account may email them when it syncs, and on a local calendar no + one is notified. The form tells you which applies. Picking a guest from + contacts needs no contacts permission. +- Pick a location from your contacts. A contacts button beside the location + field drops a contact's address straight into an event — handy for a meeting + at someone's home or office. Like the guest picker, it needs no contacts + permission. +- A "New event" Quick Settings tile. Add it to your quick settings to jump + straight into the new-event form from anywhere. Settings → New event has a + one-tap button to add the tile (Android 13+); on older versions you can add it + from the quick-settings editor. - Snooze and dismiss buttons on reminder notifications. Dismiss clears the reminder; snooze hides it and brings it back after a delay you pick in Settings → Notifications (5 to 60 minutes, default 10). Android's calendar system won't re-post a reminder on its own, so Calendula schedules an exact alarm to bring a snoozed one back on time. +### Changed +- Event details now show each guest's email beneath their name, instead of only + when no name is available. +- Crash and problem reports now open on the project's public Codeberg tracker, + where anyone can register and file an issue. Nothing is sent automatically — + you still review the report and submit it yourself in the browser. + ## [2.7.5] — 2026-06-21 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28b8251..0a076a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 20705 - versionName = "2.7.5" + versionCode = 20800 + versionName = "2.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/20800.txt b/fastlane/metadata/android/en-US/changelogs/20800.txt new file mode 100644 index 0000000..3d896a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20800.txt @@ -0,0 +1,38 @@ +### Added +- Find events fast. A search button in the top bar of every calendar view opens + a search box — type a couple of letters and matching events (by title, + location or description) appear, soonest first with past events below. Tap a + result to open it. Search covers your whole calendar, not just what's on + screen, and skips calendars you've hidden. A recurring event shows its next + occurrence rather than the date the series first started. +- A current-time line in the day and week views. A thin coloured line marks the + present moment across today's column, so you can see at a glance where you are + in the day. It updates every minute and only appears when today is in view. +- Add and remove event guests. The create/edit form now has a Guests section: + add people by email or pick them from your contacts, mark each as required or + optional, and remove them. Calendula never sends invitations itself (it has no + internet access) — it only records the guests; if the event lives on a synced + account, that account may email them when it syncs, and on a local calendar no + one is notified. The form tells you which applies. Picking a guest from + contacts needs no contacts permission. +- Pick a location from your contacts. A contacts button beside the location + field drops a contact's address straight into an event — handy for a meeting + at someone's home or office. Like the guest picker, it needs no contacts + permission. +- A "New event" Quick Settings tile. Add it to your quick settings to jump + straight into the new-event form from anywhere. Settings → New event has a + one-tap button to add the tile (Android 13+); on older versions you can add it + from the quick-settings editor. +- Snooze and dismiss buttons on reminder notifications. Dismiss clears the + reminder; snooze hides it and brings it back after a delay you pick in + Settings → Notifications (5 to 60 minutes, default 10). Android's calendar + system won't re-post a reminder on its own, so Calendula schedules an exact + alarm to bring a snoozed one back on time. + +### Changed +- Event details now show each guest's email beneath their name, instead of only + when no name is available. +- Crash and problem reports now open on the project's public Codeberg tracker, + where anyone can register and file an issue. Nothing is sent automatically — + you still review the report and submit it yourself in the browser. +