From 3c9767387b480b00c30c56cb6d447ce584748eba Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 8 Jul 2026 18:02:13 +0200 Subject: [PATCH 1/9] feat(settings): custom snooze duration, extract editor card to floret-kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Snooze-duration setting gains a Custom… option: a new single-select SnoozeDurationPicker keeps the minute presets and adds a Custom row that expands an amount field with a Minutes/Hours toggle, so any delay is settable (not just the fixed presets). Closes the settings half of the snooze request. The three presets-plus-custom editors (reminder default, agenda range, snooze) now delegate to floret-kit's new CustomAmountEditor instead of each duplicating the tonal editor card — the app keeps its domain math, strings and labels. Re-pin the floret-kit submodule to the branch commit carrying CustomAmountEditor. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/common/Picker.kt | 230 +++++++++++------- .../calendula/ui/settings/SettingsScreen.kt | 6 +- floret-kit | 2 +- 3 files changed, 141 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index c361907..1aefd87 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -1,26 +1,14 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Checkbox -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.floret.components.DialogAmountField +import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position @@ -195,56 +182,18 @@ private fun CustomReminderEditor( onConfirm: (Int) -> Unit, ) { val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - // A Position.Bottom shape: tight top corners meeting the row, full bottom. - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Unit toggle first so it stays visible above the keyboard once the - // amount field (the bottom row) is focused and scrolled into view. - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - ReminderUnit.entries.forEachIndexed { index, entry -> - SegmentedButton( - selected = unit == entry, - onClick = { onUnitChange(entry) }, - shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size), - label = { Text(stringResource(reminderUnitLabel(entry))) }, - ) - } - } - // Amount, a live preview of the lead time it resolves to, and Set — - // all on one row, sitting just above the keyboard. - Row(verticalAlignment = Alignment.CenterVertically) { - DialogAmountField( - value = amountText, - onValueChange = onAmountChange, - placeholder = "10", - ) - Spacer(Modifier.width(16.dp)) - Text( - text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) } - ?: stringResource(R.string.reminder_custom_amount), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(16.dp)) - FilledTonalButton( - onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } }, - enabled = amount != null, - ) { - Text(stringResource(R.string.reminder_custom_set)) - } - } - } - } + CustomAmountEditor( + amountText = amountText, + onAmountChange = onAmountChange, + unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) }, + selectedUnit = unit.ordinal, + onUnitChange = { onUnitChange(ReminderUnit.entries[it]) }, + preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) } + ?: stringResource(R.string.reminder_custom_amount), + setLabel = stringResource(R.string.reminder_custom_set), + confirmEnabled = amount != null, + onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, + ) } /** A short explanatory paragraph shown under a picker's title, above the rows. */ @@ -364,41 +313,136 @@ private fun CustomDaysEditor( ) { val days = amountText.toIntOrNull() ?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, + CustomAmountEditor( + amountText = amountText, + onAmountChange = onAmountChange, + placeholder = "30", + preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) } + ?: stringResource(R.string.agenda_range_custom_hint), + setLabel = stringResource(R.string.reminder_custom_set), + confirmEnabled = days != null, + onConfirm = { days?.let(onConfirm) }, + ) +} + +/** + * Snooze-duration picker, full-screen and **single-select**: the [presets] + * (whole-minute delays) each sit as a checkmark row, with a "Custom" row that + * expands an inline amount field plus a Minutes/Hours unit toggle to enter an + * arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking + * a preset or confirming a custom value applies via [onSelect] and closes. + * [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and + * is reused for both the rows and the custom preview. + */ +@Composable +fun SnoozeDurationPicker( + title: String, + presets: List, + selected: Int, + label: @Composable (Int) -> String, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val customSelected = selected !in presets + val rowCount = presets.size + 1 // + the custom row + + var customExpanded by rememberSaveable { mutableStateOf(false) } + var amountText by rememberSaveable { + mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "") + } + var unit by rememberSaveable { + mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes) + } + + FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) { + presets.forEachIndexed { index, minute -> + val isSelected = minute == selected + GroupedRow( + title = label(minute), + position = positionOf(index, rowCount), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(minute) + onDismiss() + }, + ) + } + // The Custom row connects downward into the editor card when expanded, so + // the two read as one grouped container (the shared custom-expand pattern). + GroupedRow( + title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom), + position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), + selected = customSelected, + trailing = if (customSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { customExpanded = !customExpanded }, + ) + AnimatedVisibility( + visible = customExpanded, + enter = expandEnter(), + exit = collapseExit(), ) { - DialogAmountField( - value = amountText, - onValueChange = onAmountChange, - placeholder = "30", + CustomSnoozeEditor( + amountText = amountText, + onAmountChange = { amountText = it }, + unit = unit, + onUnitChange = { unit = it }, + label = label, + onConfirm = { minutes -> + onSelect(minutes) + onDismiss() + }, ) - Spacer(Modifier.width(16.dp)) - Text( - text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) } - ?: stringResource(R.string.agenda_range_custom_hint), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(16.dp)) - FilledTonalButton( - onClick = { days?.let(onConfirm) }, - enabled = days != null, - ) { - Text(stringResource(R.string.reminder_custom_set)) - } } } } +/** Whole hours if the delay divides evenly, else minutes. */ +private fun snoozeCustomUnit(minutes: Int): ReminderUnit = + if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes + +private fun snoozeCustomAmount(minutes: Int): Int = + if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes + +/** + * The expanded "Custom" snooze editor: a tonal card connected to the Custom row + * above it. A Minutes/Hours unit toggle, an amount field with a live preview of + * the delay it resolves to, and a tonal confirm enabled only for a valid 1–999 + * amount. [onConfirm] receives the final delay in minutes. + */ +@Composable +private fun CustomSnoozeEditor( + amountText: String, + onAmountChange: (String) -> Unit, + unit: ReminderUnit, + onUnitChange: (ReminderUnit) -> Unit, + label: @Composable (Int) -> String, + onConfirm: (Int) -> Unit, +) { + val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) } + val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 } + CustomAmountEditor( + amountText = amountText, + onAmountChange = onAmountChange, + unitLabels = units.map { stringResource(reminderUnitLabel(it)) }, + selectedUnit = units.indexOf(unit).coerceAtLeast(0), + onUnitChange = { onUnitChange(units[it]) }, + preview = amount?.let { label(it * unit.minutesFactor) } + ?: stringResource(R.string.reminder_custom_amount), + setLabel = stringResource(R.string.reminder_custom_set), + confirmEnabled = amount != null, + onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } }, + ) +} + /** Human label for an [AgendaRange] (used by the picker rows and settings summary). */ @Composable fun agendaRangeLabel(range: AgendaRange): String = when (range) { 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 715f81d..d56212d 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 @@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon @@ -1174,10 +1175,9 @@ private fun NotificationsScreen( } if (showSnooze) { - OptionPicker( + SnoozeDurationPicker( title = stringResource(R.string.settings_snooze_duration), - predictiveBack = true, - options = SNOOZE_PRESETS, + presets = SNOOZE_PRESETS, selected = state.snoozeMinutes, label = { snoozeDurationLabel(it) }, onSelect = { viewModel.setSnoozeMinutes(it) }, diff --git a/floret-kit b/floret-kit index 78c5fd1..2124227 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 78c5fd1fd44a063e6de67c6209af1701c69fb316 +Subproject commit 2124227a7f462b46b5833250bc26d2ae6c7e2cd0 From 4503847c0dc26db100880deb46785b03f966c556 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 16:11:32 +0200 Subject: [PATCH 2/9] feat(edit): move an event to another calendar (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar row in the editor is now tappable when editing an existing event: picking a different calendar moves the event there on save, rather than forcing a delete-and-recreate. CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the move is copy+delete: the master row is re-inserted on the target calendar (preserving UID_2445 so backup dedup and sync identity survive), its reminders and editable guests are copied, and — for a recurring series — every exception is replayed against the new master (modified occurrences via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's field edits are then applied with the normal series update. Everything on the new side is built before the source is deleted (post-before-delete), with a rollback of the copy on any failure, so a move is all-or-nothing. A calendar change forces whole-series scope, so it skips the recurring scope dialog. Managed special-dates calendars stay locked. Colour is not carried across (a raw/keyed colour may be invalid on the target account), matching the existing calendar-switch behaviour. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++ .../data/calendar/CalendarDataSource.kt | 205 ++++++++++++++++++ .../data/calendar/CalendarRepository.kt | 14 ++ .../data/calendar/CalendarRepositoryImpl.kt | 11 + .../data/calendar/EventWriteMapper.kt | 130 ++++++++++- .../calendula/data/calendar/Projections.kt | 80 +++++++ .../calendula/ui/edit/EventEditScreen.kt | 9 +- .../calendula/ui/edit/EventEditViewModel.kt | 18 +- .../calendar/CalendarRepositoryImplTest.kt | 40 ++++ .../data/calendar/EventWriteMapperTest.kt | 110 ++++++++++ .../data/calendar/FakeCalendarDataSource.kt | 20 ++ .../ui/edit/EventEditViewModelTest.kt | 158 ++++++++++++++ 12 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8da9d1..8db11ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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] + +### Added +- Move an event to another calendar. When editing an existing event, the + calendar row is now tappable — pick a different calendar and saving moves the + event across, instead of having to delete it and recreate it elsewhere. + Recurring series move as a whole, keeping their individually-edited and + cancelled occurrences, and any reminders and guests come along too. A calendar + can't simply be reassigned underneath an event, so Calendula recreates it on + the target and removes the original — the same approach other calendar apps + take. Thanks to @prismplex for the suggestion ([#39]). + ## [2.14.0] — 2026-07-06 ### Added @@ -874,3 +886,4 @@ automatically, with zero telemetry and no internet permission. [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 +[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 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 eeb7821..9dd6112 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 @@ -18,6 +18,8 @@ import android.util.Log import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee +import de.jeanlucmakiola.calendula.domain.AttendeeRelationship +import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption @@ -176,6 +178,25 @@ interface CalendarDataSource { allDayReminderTimeMinutes: Int, ) + /** + * Move an event (for recurring events: the whole series, with its modified + * and cancelled occurrences) to [targetCalendarId], returning the new + * `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in + * place, so this is copy+delete: the row is re-inserted on the target (its + * `UID_2445` preserved), its exceptions, reminders and editable guests + * replayed, the [original]→[updated] field edits applied, then the source + * deleted. The source is removed only once the copy fully succeeds; a failure + * before that rolls the new event back, leaving the original untouched. + * [allDayReminderTimeMinutes]: see [insertEvent]. + */ + fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long + /** * Change a single occurrence of a recurring event by inserting a * modified-occurrence exception at [beginMillis] (the occurrence's @@ -876,6 +897,190 @@ class AndroidCalendarDataSource @Inject constructor( } } + override fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long { + // CALENDAR_ID can't be updated in place, so re-create the event on the + // target calendar and delete the source. Everything that identifies or + // hangs off the event is copied first; the source row is removed only + // once the copy (including its exceptions) is complete — a failure + // anywhere before that rolls the new event back, so the move is + // all-or-nothing and never leaves a half-copied duplicate. + val master = queryMoveMaster(eventId) + ?: throw WriteFailedException("read event to move id=$eventId") + // Keep the source UID so an .ics backup dedups and a sync adapter can + // recognise the moved event; mint one only if the source carried none. + val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" + val newEventId = resolver.insert( + CalendarContract.Events.CONTENT_URI, + buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(), + )?.let(ContentUris::parseId) + ?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId") + try { + copyReminderRows(fromEventId = eventId, toEventId = newEventId) + insertAttendees(newEventId, editableAttendees(eventId)) + copyExceptions(fromEventId = eventId, toEventId = newEventId) + // Apply the user's field edits exactly as a same-calendar "all + // events" save would: the moved master carries the source's values, + // so the dirty diff against [original] writes only what changed + // (including a time or rrule edit made in the same save). + updateEvent(newEventId, original, updated, allDayReminderTimeMinutes) + } catch (t: Throwable) { + // Undo the partial copy so a failed move leaves nothing behind; the + // source is still intact (it's deleted only past this point). + runCatching { deleteEvent(newEventId) } + throw t + } + deleteEvent(eventId) + return newEventId + } + + /** The master row of the event to move, as a verbatim-insert snapshot. */ + private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + MoveMasterProjection.COLUMNS, + null, null, null, + )?.use { c -> + if (!c.moveToFirst()) return@use null + val r = CursorColumnReader(c) + MoveMaster( + uid = r.getString(MoveMasterProjection.IDX_UID), + event = MasterEventSnapshot( + title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), + isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, + dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), + dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) + .takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) }, + duration = r.getString(MoveMasterProjection.IDX_DURATION), + rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, + rdate = r.getString(MoveMasterProjection.IDX_RDATE), + exdate = r.getString(MoveMasterProjection.IDX_EXDATE), + timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), + availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), + accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), + status = r.getInt(MoveMasterProjection.IDX_STATUS) + .takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) }, + location = r.getString(MoveMasterProjection.IDX_LOCATION), + description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), + ), + ) + } + + private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot) + + /** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */ + private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query( + CalendarContract.Reminders.CONTENT_URI, + arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD), + CalendarContract.Reminders.EVENT_ID + " = ?", + arrayOf(fromEventId.toString()), + null, + )?.use { c -> + while (c.moveToNext()) { + val values = ContentValues().apply { + put(CalendarContract.Reminders.EVENT_ID, toEventId) + put(CalendarContract.Reminders.MINUTES, c.getInt(0)) + put(CalendarContract.Reminders.METHOD, c.getInt(1)) + } + if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) { + Log.w(TAG, "Failed to copy a reminder to moved event $toEventId") + } + } + } ?: Unit + + /** + * The event's editable guests as [EventAttendee]s — the rows the move + * re-creates. Mirrors the edit form's filter: the organizer and resource + * rows (backend-owned, not user-editable) and any without an email are + * dropped; the response status resets to "invited" on re-insert. + */ + private fun editableAttendees(eventId: Long): List = queryAttendees(eventId) + .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) + } + } + + /** + * Replay every exception of the series [fromEventId] against the moved series + * [toEventId]. The copy preserved the recurrence skeleton, so each occurrence + * time still resolves: a cancelled occurrence is re-hidden with a cancelled + * exception, a modified one is re-inserted with its fields (then its reminders + * reconciled and editable guests copied). No-op for a non-recurring event. + */ + private fun copyExceptions(fromEventId: Long, toEventId: Long) { + queryExceptionRows(fromEventId).forEach { ex -> + if (ex.isCancelled) { + val values = ContentValues().apply { + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis) + put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) + } + resolver.insert( + ContentUris.withAppendedId( + CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, + ), + values, + ) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId") + } else { + val uri = resolver.insert( + ContentUris.withAppendedId( + CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, + ), + buildCopiedExceptionValues(ex).toContentValues(), + ) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId") + val newExceptionId = ContentUris.parseId(uri) + // The provider may clone the parent's reminders onto the new + // exception; reconcile to the source exception's exact set so + // they neither double nor drop. Same DTSTART → same all-day + // encoding, so the stored offsets match directly. + reconcileReminders( + newExceptionId, + queryReminders(ex.exceptionEventId).map { it.minutes }, + ) + insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId)) + } + } + } + + /** The series' exception rows (modified + cancelled), oldest occurrence first. */ + private fun queryExceptionRows(seriesEventId: Long): List = resolver.query( + CalendarContract.Events.CONTENT_URI, + ExceptionProjection.COLUMNS, + "${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0", + arrayOf(seriesEventId.toString()), + CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", + )?.use { c -> + c.mapAll { + val r = CursorColumnReader(c) + val status = r.getInt(ExceptionProjection.IDX_STATUS) + .takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) } + ExceptionRowSnapshot( + exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), + originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), + isCancelled = status == CalendarContract.Events.STATUS_CANCELED, + status = status, + title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(), + isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0, + dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART), + dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND) + .takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) }, + duration = r.getString(ExceptionProjection.IDX_DURATION), + timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE), + availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY), + accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL), + location = r.getString(ExceptionProjection.IDX_LOCATION), + description = r.getString(ExceptionProjection.IDX_DESCRIPTION), + ) + } + } ?: emptyList() + override fun updateOccurrence( eventId: Long, beginMillis: Long, 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 422e361..c006159 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 @@ -61,6 +61,20 @@ interface CalendarRepository { */ suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm) + /** + * Move an event (recurring: the whole series, with its exceptions) to + * [targetCalendarId] and apply the [original]→[updated] field edits; returns + * the new event's `Events._ID`. Copy+delete under the hood + * (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in + * place. + */ + suspend fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + ): Long + /** * Change a single occurrence of a recurring event (exception row with the * form's values); returns the exception's `Events._ID`. 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 b7fd51e..f9528d2 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 @@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.deleteEvent(eventId) } + override suspend fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + ): Long = withContext(io) { + dataSource.moveEvent( + eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(), + ) + } + override suspend fun updateOccurrence( eventId: Long, beginMillis: Long, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index dc830aa..a8ad518 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa * DURATION instead of DTEND when an RRULE is set): whole days for all-day * events, seconds otherwise. */ -internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) { - "P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D" +internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = + rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay) + +/** + * RFC 2445 duration for a [spanMillis]-long event: whole days for all-day + * events (the provider's convention), seconds otherwise. Shared by the write + * paths that need a DURATION but start from a raw millisecond span (the series + * copy and exception replay of a calendar move) rather than [EventWriteTimes]. + */ +internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) { + "P${spanMillis / MILLIS_PER_DAY}D" } else { - "P${(dtEndMillis - dtStartMillis) / 1_000L}S" + "P${spanMillis / 1_000L}S" } /** @@ -182,6 +191,121 @@ internal fun buildOccurrenceExceptionValues( putAll(eventColorColumns(form.colorKey, form.color)) } +/** + * Raw provider snapshot of a master/one-off Events row, enough to re-insert it + * verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID` + * is sync-adapter-owned and can't be updated in place). Recurring rows carry + * [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis]; + * one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw + * `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target + * account, so the moved copy inherits the target calendar's colour instead. + */ +internal data class MasterEventSnapshot( + val title: String, + val isAllDay: Boolean, + val dtStartMillis: Long, + val dtEndMillis: Long?, + val duration: String?, + val rrule: String?, + val rdate: String?, + val exdate: String?, + val timezone: String?, + val availability: Int, + val accessLevel: Int, + val status: Int?, + val location: String?, + val description: String?, +) + +/** + * Column values re-creating [snapshot] as a fresh Events row on + * [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity + * survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION, + * RDATE/EXDATE) so the series' generated instances — and therefore the + * ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The + * caller layers the user's field edits on top with a normal series update. + */ +internal fun buildMovedMasterValues( + snapshot: MasterEventSnapshot, + targetCalendarId: Long, + uid: String, +): Map = buildMap { + put(CalendarContract.Events.CALENDAR_ID, targetCalendarId) + put(CalendarContract.Events.UID_2445, uid) + put(CalendarContract.Events.TITLE, snapshot.title) + put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis) + put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC") + if (snapshot.rrule != null) { + put(CalendarContract.Events.RRULE, snapshot.rrule) + snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) } + snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) } + put(CalendarContract.Events.DURATION, snapshot.movedDuration()) + } else { + snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) } + } + put(CalendarContract.Events.AVAILABILITY, snapshot.availability) + put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) + snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) + put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) +} + +/** The recurring copy's DURATION: its own if present, else derived from DTEND. */ +private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() } + ?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay) + +/** + * Raw provider snapshot of one exception row of a recurring series (a modified + * or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis] + * ties it to the occurrence it overrides; a [isCancelled] row only needs that. + */ +internal data class ExceptionRowSnapshot( + val exceptionEventId: Long, + val originalInstanceMillis: Long, + val isCancelled: Boolean, + val status: Int?, + val title: String, + val isAllDay: Boolean, + val dtStartMillis: Long, + val dtEndMillis: Long?, + val duration: String?, + val timezone: String?, + val availability: Int, + val accessLevel: Int, + val location: String?, + val description: String?, +) + +/** + * Column values replaying a *modified* occurrence [snapshot] against the moved + * series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the + * length travels as DURATION (the provider rejects DTEND on an exception). A + * cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME + + * STATUS_CANCELED) — this builder is only for the modified case. + */ +internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map = + buildMap { + put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis) + put(CalendarContract.Events.TITLE, snapshot.title) + put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis) + put( + CalendarContract.Events.DURATION, + snapshot.duration?.takeIf { it.isNotBlank() } + ?: rfc2445Duration( + (snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis, + snapshot.isAllDay, + ), + ) + put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC") + put(CalendarContract.Events.AVAILABILITY, snapshot.availability) + put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) + put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) + put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) + snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + } + /** * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A * [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the 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 2066ac7..0ab8f79 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 @@ -186,6 +186,86 @@ internal object SearchProjection { const val IDX_RDATE = 11 } +/** + * The master/one-off Events row of an event about to be moved to another + * calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries + * the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy + * generates the same instances, and `UID_2445` so identity survives the move. + */ +internal object MoveMasterProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events.UID_2445, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.RRULE, + CalendarContract.Events.RDATE, + CalendarContract.Events.EXDATE, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.AVAILABILITY, + CalendarContract.Events.ACCESS_LEVEL, + CalendarContract.Events.STATUS, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + ) + + const val IDX_UID = 0 + const val IDX_TITLE = 1 + const val IDX_DTSTART = 2 + const val IDX_DTEND = 3 + const val IDX_DURATION = 4 + const val IDX_RRULE = 5 + const val IDX_RDATE = 6 + const val IDX_EXDATE = 7 + const val IDX_EVENT_TIMEZONE = 8 + const val IDX_ALL_DAY = 9 + const val IDX_AVAILABILITY = 10 + const val IDX_ACCESS_LEVEL = 11 + const val IDX_STATUS = 12 + const val IDX_LOCATION = 13 + const val IDX_DESCRIPTION = 14 +} + +/** + * The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to + * replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified + * occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters + * `DELETED = 0` so provider tombstones aren't replayed. + */ +internal object ExceptionProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events.ORIGINAL_INSTANCE_TIME, + CalendarContract.Events.STATUS, + CalendarContract.Events.TITLE, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.DURATION, + CalendarContract.Events.EVENT_TIMEZONE, + CalendarContract.Events.AVAILABILITY, + CalendarContract.Events.ACCESS_LEVEL, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.DESCRIPTION, + ) + + const val IDX_ID = 0 + const val IDX_ORIGINAL_INSTANCE_TIME = 1 + const val IDX_STATUS = 2 + const val IDX_TITLE = 3 + const val IDX_ALL_DAY = 4 + const val IDX_DTSTART = 5 + const val IDX_DTEND = 6 + const val IDX_DURATION = 7 + const val IDX_EVENT_TIMEZONE = 8 + const val IDX_AVAILABILITY = 9 + const val IDX_ACCESS_LEVEL = 10 + const val IDX_LOCATION = 11 + const val IDX_DESCRIPTION = 12 +} + internal object AttendeeProjection { val COLUMNS: Array = arrayOf( CalendarContract.Attendees.ATTENDEE_NAME, 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 66944f1..ad5b9fb 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 @@ -618,15 +618,16 @@ private fun EventEditContent( Spacer(Modifier.height(gap)) - // Calendar card — tap anywhere to pick the target calendar. Editing - // keeps the owning calendar (moving events between calendars is a - // sync-adapter minefield; every stock calendar app locks it too). + // Calendar card — tap anywhere to pick the target calendar. Picking a + // different one while editing *moves* the event (copy+delete on save, + // since CALENDAR_ID can't be updated in place). Managed special-dates + // events stay locked: the contact sync owns their calendar. EditCard( icon = Icons.Default.CalendarMonth, iconContentDescription = stringResource(R.string.event_detail_calendar), iconTint = accent, onClick = { showCalendarPicker = true } - .takeIf { state.calendars.isNotEmpty() && !state.isEditing }, + .takeIf { state.calendars.isNotEmpty() && !state.isManaged }, ) { Text( text = selectedCalendar?.displayName 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 e2da425..2de8b1e 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 @@ -439,10 +439,15 @@ class EventEditViewModel @Inject constructor( _saveState.value = SaveUiState.Saved return } + // Changing the calendar moves the event (copy+delete — CALENDAR_ID can't + // be updated in place) and is inherently whole-series: an occurrence + // can't live in a different calendar than its series, so a move skips the + // scope dialog even for a recurring event. + val movingCalendar = target != null && form.calendarId != target.original.calendarId // Managed events are a yearly series whose editable fields (reminders, // notes, location) live on the series row — never offer the scope dialog, // which would split the series into an exception the sync then reverts. - if (target != null && target.original.rrule != null && !current.isManaged) { + if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) { _saveState.value = SaveUiState.AwaitingScope return } @@ -497,6 +502,17 @@ class EventEditViewModel @Inject constructor( if (target == null) { repository.createEvent(form) prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) + } else if (form.calendarId != target.original.calendarId) { + // Move to the picked calendar (copy+delete), carrying any + // field edits made in the same save. The target becomes the + // last-used calendar, like a create does. + repository.moveEvent( + eventId = target.eventId, + targetCalendarId = requireNotNull(form.calendarId), + original = target.original, + updated = form, + ) + prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) } else { when (scope) { RecurringWriteScope.ThisEvent -> diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index ed8e728..14fa836 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -312,6 +312,46 @@ class CalendarRepositoryImplTest { assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated)) } + @Test + fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + val original = EventForm( + calendarId = 1L, + title = "Stand-up", + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)), + ) + val updated = original.copy(calendarId = 3L) + + val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated) + + assertThat(newId).isEqualTo(77L) + assertThat(fake.movedEvents).containsExactly( + FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated), + ) + } + + @Test + fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { + writeError = WriteFailedException("insert moved event into calendar id=3") + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined) + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)), + ) + + try { + repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L)) + error("Expected WriteFailedException") + } catch (expected: WriteFailedException) { + assertThat(expected.message).contains("3") + } + } + @Test fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource().apply { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index f1be42b..85d8956 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -298,4 +298,114 @@ class EventWriteMapperTest { assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null) assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null) } + + // --- buildMovedMasterValues (calendar move: verbatim series copy) --- + + private fun masterSnapshot( + isAllDay: Boolean = false, + dtStartMillis: Long = 1_781_164_800_000L, + dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L, + duration: String? = null, + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + ) = MasterEventSnapshot( + title = "Standup", + isAllDay = isAllDay, + dtStartMillis = dtStartMillis, + dtEndMillis = dtEndMillis, + duration = duration, + rrule = rrule, + rdate = rdate, + exdate = exdate, + timezone = "Europe/Berlin", + availability = CalendarContract.Events.AVAILABILITY_BUSY, + accessLevel = CalendarContract.Events.ACCESS_DEFAULT, + status = CalendarContract.Events.STATUS_CONFIRMED, + location = "Room 1", + description = "", + ) + + @Test + fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() { + val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula") + assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L) + assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L) + assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) + assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION) + // Empty description clears explicitly; a raw/keyed colour is never copied + // (may be invalid on the target account — the copy inherits its colour). + assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY) + } + + @Test + fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() { + val values = buildMovedMasterValues( + masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"), + targetCalendarId = 9L, + uid = "u@calendula", + ) + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY") + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z") + // Recurring rows never carry DTEND (the provider's invariant). + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) + } + + @Test + fun `moved series without a stored duration derives it from DTEND`() { + val values = buildMovedMasterValues( + masterSnapshot(duration = null, rrule = "FREQ=DAILY"), + targetCalendarId = 9L, + uid = "u@calendula", + ) + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") + } + + // --- buildCopiedExceptionValues (calendar move: exception replay) --- + + private fun exceptionSnapshot( + isCancelled: Boolean = false, + duration: String? = null, + // Occurrence starts one hour into the day and runs 30 minutes. + dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L, + ) = ExceptionRowSnapshot( + exceptionEventId = 42L, + originalInstanceMillis = 1_781_164_800_000L, + isCancelled = isCancelled, + status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED, + title = "Moved occurrence", + isAllDay = false, + dtStartMillis = 1_781_164_800_000L + 3_600_000L, + dtEndMillis = dtEndMillis, + duration = duration, + timezone = "Europe/Berlin", + availability = CalendarContract.Events.AVAILABILITY_BUSY, + accessLevel = CalendarContract.Events.ACCESS_DEFAULT, + location = "", + description = "Notes", + ) + + @Test + fun `copied modified occurrence carries the original instance and DURATION not DTEND`() { + val values = buildCopiedExceptionValues(exceptionSnapshot()) + assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME]) + .isEqualTo(1_781_164_800_000L) + assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L) + // 30-minute occurrence derived from DTEND, written as DURATION only. + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S") + assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND) + assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null) + } + + @Test + fun `copied modified occurrence keeps a stored duration verbatim`() { + val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null)) + assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S") + } } 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 d62ddae..dce1a32 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 @@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource { val insertedForms = mutableListOf() val updatedEvents = mutableListOf>() + data class MovedEvent( + val eventId: Long, + val targetCalendarId: Long, + val original: EventForm, + val updated: EventForm, + ) + val movedEvents = mutableListOf() val updatedOccurrences = mutableListOf>() val updatedFromOccurrences = mutableListOf>() val deletedEventIds = mutableListOf() @@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource { allDayReminderTimes += allDayReminderTimeMinutes } + override fun moveEvent( + eventId: Long, + targetCalendarId: Long, + original: EventForm, + updated: EventForm, + allDayReminderTimeMinutes: Int, + ): Long { + writeError?.let { throw it } + movedEvents += MovedEvent(eventId, targetCalendarId, original, updated) + allDayReminderTimes += allDayReminderTimeMinutes + return nextInsertId + } + override fun updateOccurrence( eventId: Long, beginMillis: Long, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt new file mode 100644 index 0000000..51a9819 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModelTest.kt @@ -0,0 +1,158 @@ +package de.jeanlucmakiola.calendula.ui.edit + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Instant + +/** + * Focuses on the save-time branch that decides between an in-place update and a + * calendar *move* (copy+delete). The provider-level move itself is verified + * on-device; here the fake records which repository call the ViewModel chose. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EventEditViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + @AfterEach fun tearDown() = Dispatchers.resetMain() + + private val beginMillis = 1_781_164_800_000L + private val endMillis = beginMillis + 3_600_000L + + private fun cal(id: Long): CalendarSource = CalendarSource( + id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL", + color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, + ) + + private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup", + start = Instant.fromEpochMilliseconds(beginMillis), + end = Instant.fromEpochMilliseconds(endMillis), + isAllDay = false, color = 0xFF000000.toInt(), location = null, + ), + description = null, organizer = null, attendees = emptyList(), rrule = rrule, + ) + + // Pin the DataStore's scope to the test dispatcher so a settings write (e.g. + // last-used calendar) completes under advanceUntilIdle instead of on a real + // IO thread the virtual clock can't observe. + private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() }, + ), + ) + + private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() }, + ), + ) + + private fun viewModel( + tempDir: Path, + fake: FakeCalendarDataSource, + ): EventEditViewModel { + val p = prefs(tempDir) + val s = settings(tempDir) + val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher) + return EventEditViewModel(repo, p, s, dispatcher) + } + + /** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */ + private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} } + + @Test + fun `changing the calendar routes the save through a move, not an update`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L) } + nextInsertId = 99L + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setCalendar(2L) + vm.save() + advanceUntilIdle() + + assertThat(fake.movedEvents).hasSize(1) + assertThat(fake.movedEvents.single().eventId).isEqualTo(42L) + assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L) + assertThat(fake.updatedEvents).isEmpty() + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved) + job.cancel() + } + + @Test + fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setCalendar(2L) + vm.save() + advanceUntilIdle() + + // No AwaitingScope park: a move is inherently whole-series. + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved) + assertThat(fake.movedEvents).hasSize(1) + job.cancel() + } + + @Test + fun `editing a recurring event without moving still asks for the scope`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(cal(1L), cal(2L)) + eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") } + } + val vm = viewModel(tempDir, fake) + val job = activate(vm) + + vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis) + vm.setTitle("Renamed") + vm.save() + advanceUntilIdle() + + assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope) + assertThat(fake.movedEvents).isEmpty() + job.cancel() + } +} From 114db7939c07ba48a83f23f376592dab4261ad43 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 16:27:10 +0200 Subject: [PATCH 3/9] ci(translations): run on every PR so the required check always reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release/* branch protection requires the "Translations / check" status, but the workflow was path-filtered to translation resources. A code-only PR targeting a release branch never touches those, so the workflow never ran, never posted its status, and the required check stayed pending forever — permanently blocking the merge (only PRs that happened to change strings could satisfy it). Drop the path filter so it runs on every PR, mirroring the always-on `ci` job. The parity check is SDK-free and passes when the committed translations are consistent, so running it on unrelated PRs is effectively free. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/translations.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/translations.yaml b/.gitea/workflows/translations.yaml index 0d2e8f8..c621258 100644 --- a/.gitea/workflows/translations.yaml +++ b/.gitea/workflows/translations.yaml @@ -3,13 +3,15 @@ name: Translations # Fast, SDK-free parity check for translation resources, so Weblate PRs (which # only touch values-*/strings.xml) get quick feedback without the full Android # build. The deeper checks still run in CI via lintDebug (ExtraTranslation). +# +# Runs on every PR (no path filter) so the required "Translations / check" +# status is always reported — like the `ci` job. A path-filtered workflow is +# skipped on unrelated PRs and never posts its status, which leaves that +# required check pending forever and blocks the merge of any code-only PR into a +# release/* branch. The check itself is cheap and simply passes when the +# committed translations are consistent, so always running it costs nothing. on: pull_request: - paths: - - 'app/src/main/res/values*/strings.xml' - - 'app/src/main/res/xml/locales_config.xml' - - 'scripts/check_translations.py' - - '.gitea/workflows/translations.yaml' concurrency: group: translations-${{ github.ref }} From 0221972e6d5d9c468fd671b20ececa72a015d366 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 16:38:35 +0200 Subject: [PATCH 4/9] docs(changelog): add the custom snooze duration entry (#40) The custom snooze duration shipped in 3c97673 but had no changelog entry; add it to the Unreleased section so it's in the 2.15.0 release notes. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db11ab..83143b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- A custom snooze duration. The **Snooze duration** setting (Settings → + Notifications) gains a **Custom…** option next to the minute presets: pick any + amount and switch between minutes and hours, so a snoozed reminder comes back + after exactly the delay you want instead of only a preset one ([#40]). - Move an event to another calendar. When editing an existing event, the calendar row is now tappable — pick a different calendar and saving moves the event across, instead of having to delete it and recreate it elsewhere. @@ -887,3 +891,4 @@ automatically, with zero telemetry and no internet permission. [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39 +[#40]: https://codeberg.org/jlmakiola/calendula/issues/40 From 38a35be0f0003904ede947f9cf0dafa8fe56889d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Jul 2026 17:25:51 +0200 Subject: [PATCH 5/9] fix(reminders): show the day for reminders on another day (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reminder fired ahead of an event on a different day showed only the event's time (e.g. "09:30 – 10:00"), making it look like it was happening today. reminderTimeText now prefixes timed events with a relative day: "Tomorrow"/"Yesterday", the short weekday for another day this week, or the exact date for anything further out. The this-week boundary honours the user's "week starts on" setting: the resolved first day of week is threaded through from ReminderNotifier, so e.g. a Sunday reads as next week under a Sunday-start locale. All-day events keep their explicit date (never ambiguous), and cross-midnight timed events keep both explicit dates. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++ .../data/reminders/ReminderNotifier.kt | 17 ++- .../data/reminders/ReminderTimeText.kt | 84 +++++++++++- app/src/main/res/values/strings.xml | 3 + .../data/reminders/ReminderTimeTextTest.kt | 127 ++++++++++++++---- 5 files changed, 205 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83143b7..1069d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the target and removes the original — the same approach other calendar apps take. Thanks to @prismplex for the suggestion ([#39]). +### Fixed +- Reminders for events on another day no longer read as if they were today. A + reminder fired ahead of time — say, the day before — used to show only the + event's time, making it look like it was happening now. The notification now + says which day: **Tomorrow** or **Yesterday**, the weekday for another day this + week, or the date for anything further out. Thanks to @moonj for the report + ([#46]). + ## [2.14.0] — 2026-07-06 ### Added @@ -892,3 +900,4 @@ automatically, with zero telemetry and no internet permission. [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39 [#40]: https://codeberg.org/jlmakiola/calendula/issues/40 +[#46]: https://codeberg.org/jlmakiola/calendula/issues/46 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 9bd0dde..73a4656 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 @@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import kotlinx.coroutines.flow.first +import kotlinx.datetime.isoDayNumber +import java.time.DayOfWeek +import java.time.Instant import java.time.ZoneId import java.util.Locale import javax.inject.Inject @@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor( val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val is24Hour = settingsPrefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(context)) + val zone = ZoneId.systemDefault() + val locale = Locale.getDefault() + // resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by + // its shared ISO number (1..7) for the date math in reminderTimeText. + val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber) val time = reminderTimeText( beginMillis = alert.beginMillis, endMillis = alert.endMillis, isAllDay = alert.isAllDay, - zone = ZoneId.systemDefault(), - locale = Locale.getDefault(), + zone = zone, + locale = locale, is24Hour = is24Hour, + today = Instant.now().atZone(zone).toLocalDate(), + firstDayOfWeek = firstDayOfWeek, + tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), + yesterdayLabel = context.getString(R.string.reminder_day_yesterday), ) val text = listOfNotNull(time, alert.location).joinToString(" · ") val notification = NotificationCompat.Builder(context, CHANNEL_ID) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index ce7fd21..cdc7462 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -1,23 +1,37 @@ package de.jeanlucmakiola.calendula.data.reminders import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter +import java.time.DayOfWeek import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit import java.util.Locale /** * The one line of time context in a reminder notification. Pure so it can be - * JVM-tested: + * JVM-tested. * - * - timed, same day: "09:30 – 10:00" - * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time) + * Timed events that fall on a day other than [today] are prefixed with that + * day, so a reminder fired ahead of time no longer reads as if the event were + * today (issue #46). The prefix prefers natural language and stays short: + * + * - today: "09:30 – 10:00" (no prefix) + * - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel]) + * - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday) + * - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday + * alone would be ambiguous) + * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time, + * already unambiguous) * - all-day, one day: "11 Jun 2026" * - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026" * - * All-day instances store UTC midnights with an exclusive end, so they are + * All-day instances already carry an explicit date, so they never gain a + * relative prefix. They store UTC midnights with an exclusive end, so they are * read in UTC and the end day is the last *covered* day. */ fun reminderTimeText( @@ -27,6 +41,10 @@ fun reminderTimeText( zone: ZoneId, locale: Locale, is24Hour: Boolean, + today: LocalDate, + firstDayOfWeek: DayOfWeek, + tomorrowLabel: String, + yesterdayLabel: String, ): String { if (isAllDay) { val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) @@ -43,18 +61,70 @@ fun reminderTimeText( } val timeFormat = timeOfDayFormatter(is24Hour, locale) + val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone) return if (begin.toLocalDate() == end.toLocalDate()) { - timeFormat.format(begin) + RANGE + timeFormat.format(end) + val range = timeFormat.format(begin) + RANGE + timeFormat.format(end) + val prefix = relativeDayPrefix( + day = begin.toLocalDate(), + today = today, + firstDayOfWeek = firstDayOfWeek, + locale = locale, + dateFormat = dateFormat, + tomorrowLabel = tomorrowLabel, + yesterdayLabel = yesterdayLabel, + ) + if (prefix == null) range else "$prefix, $range" } else { // Cross-day: medium date + the chosen short time, joined per side. Built // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice - // applies to the time portion too. - val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + // applies to the time portion too. The explicit dates already say which + // day, so no relative prefix is layered on top. val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" } dateTime(begin) + RANGE + dateTime(end) } } +/** + * A short label for [day] relative to [today], or `null` when it *is* today (the + * common case, which needs no prefix). Weekday names are used only within the + * current week — a "next Wednesday" would be indistinguishable from this one, so + * anything past this week falls back to the exact date. + */ +private fun relativeDayPrefix( + day: LocalDate, + today: LocalDate, + firstDayOfWeek: DayOfWeek, + locale: Locale, + dateFormat: DateTimeFormatter, + tomorrowLabel: String, + yesterdayLabel: String, +): String? = when (ChronoUnit.DAYS.between(today, day)) { + 0L -> null + 1L -> tomorrowLabel + -1L -> yesterdayLabel + else -> if (isSameWeek(day, today, firstDayOfWeek)) { + day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale) + } else { + dateFormat.format(day) + } +} + +/** + * True when [day] and [today] share the same week. The week boundary honours the + * user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek], + * with [firstDayOfWeek] falling back to the locale default upstream). + */ +private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean { + val startOfWeek = today.previousOrSame(firstDayOfWeek) + return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1)) +} + +/** The most recent [target] on or before this date (this date itself when it matches). */ +private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate { + val backtrack = (dayOfWeek.value - target.value + 7) % 7 + return minusDays(backtrack.toLong()) +} + private const val RANGE = " – " diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87dd845..9966ef9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,6 +226,9 @@ Not now Snooze Dismiss + + Tomorrow + Yesterday Month diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index 4d3f6c6..c8014a1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test +import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -18,41 +19,110 @@ class ReminderTimeTextTest { private fun utcMidnight(date: LocalDate): Long = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + /** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */ + private fun text( + beginMillis: Long, + endMillis: Long, + isAllDay: Boolean = false, + zone: ZoneId = berlin, + locale: Locale = Locale.GERMANY, + is24Hour: Boolean = true, + today: LocalDate? = null, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + ): String = reminderTimeText( + beginMillis = beginMillis, + endMillis = endMillis, + isAllDay = isAllDay, + zone = zone, + locale = locale, + is24Hour = is24Hour, + today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(), + firstDayOfWeek = firstDayOfWeek, + tomorrowLabel = "Tomorrow", + yesterdayLabel = "Yesterday", + ) + @Test - fun `timed event on one day shows just the time range`() { - val text = reminderTimeText( + fun `timed event today shows just the time range`() { + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin), - isAllDay = false, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, ) assertThat(text).isEqualTo("09:30 – 10:00") } @Test fun `12-hour preference renders an am-pm time range`() { - val text = reminderTimeText( + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin), - isAllDay = false, - zone = berlin, locale = Locale.US, is24Hour = false, ) assertThat(text).isEqualTo("2:00 PM – 3:00 PM") } + @Test + fun `tomorrow's event is prefixed with the tomorrow label`() { + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00") + } + + @Test + fun `yesterday's event is prefixed with the yesterday label`() { + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00") + } + + @Test + fun `an event later this week is prefixed with the short weekday`() { + // 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("So., 09:30 – 10:00") + } + + @Test + fun `the week boundary honours the week-start setting`() { + // Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the + // Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week + // and must render as a date — the opposite of the Monday-start case above. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00") + } + + @Test + fun `an event next week falls back to the exact date, not a weekday`() { + // 2026-06-15 is the following Monday — a weekday alone would be ambiguous. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00") + } + @Test fun `timed event crossing midnight includes both dates`() { - val text = reminderTimeText( + val text = text( beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin), endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin), - isAllDay = false, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, ) assertThat(text).contains("11.06.2026") assertThat(text).contains("12.06.2026") @@ -61,28 +131,35 @@ class ReminderTimeTextTest { @Test fun `all-day single day shows one date, read in UTC`() { - val text = reminderTimeText( + val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), isAllDay = true, // Zone must not matter for all-day events: UTC midnight is // 02:00 in Berlin — naive local reading would shift the day. - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026") } + @Test + fun `all-day event tomorrow still shows the exact date, no relative prefix`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("12.06.2026") + } + @Test fun `all-day multi-day shows the last covered day, not the exclusive end`() { - val text = reminderTimeText( + val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), isAllDay = true, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") } @@ -90,13 +167,11 @@ class ReminderTimeTextTest { @Test fun `degenerate all-day range never renders an inverted span`() { val day = utcMidnight(LocalDate.of(2026, 6, 11)) - val text = reminderTimeText( + val text = text( beginMillis = day, endMillis = day, isAllDay = true, - zone = berlin, - locale = Locale.GERMANY, - is24Hour = true, + today = LocalDate.of(2026, 6, 11), ) assertThat(text).isEqualTo("11.06.2026") } From ab631365b2e9f01b481f8d77ea964bdc41e00bfb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 12:22:06 +0200 Subject: [PATCH 6/9] feat(intent): handle ACTION_EDIT and broaden .ics MIME types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the calendar-intent surface toward AOSP/Etar parity — the app already handled VIEW (date + event), INSERT, and .ics open/share, but was missing the edit action and the alternate .ics MIME labels. - ACTION_EDIT on content://com.android.calendar/events/ now opens the event in the edit form (previously only VIEW → read-only detail existed). An assistant, task app, or widget can hand an event to Calendula to edit. A bare EDIT URI with no occurrence extras falls back to the event row's own DTSTART/DTEND, mirroring the #48 view-event fallback. - ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same prefilled create form as ACTION_INSERT. - The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar 1.0 / .vcs) and application/ics — the alternate labels the same calendar data arrives under from some file/mail apps (matches Etar's ImportActivity). Deliberately excluded: webcal:// / http(s) remote-calendar subscription (needs INTERNET, which the app doesn't have) and the Google-web-link handler (Google-specific + network). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 36 +++++++++++-- .../jeanlucmakiola/calendula/MainActivity.kt | 50 +++++++++++++++++-- .../calendula/ui/CalendarHost.kt | 16 ++++++ .../jeanlucmakiola/calendula/ui/RootScreen.kt | 4 ++ .../calendula/ui/edit/EventEditViewModel.kt | 17 +++++-- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47866af..930ab70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,32 +97,58 @@ - + - - + + + + + - + + + + (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the + dir mime is AOSP's "edit a new event" — i.e. create — so it maps + to the same prefilled create form. --> + + + + + + + + Search @@ -321,6 +322,8 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + Always show today + Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt new file mode 100644 index 0000000..968b3ce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +/** Covers the agenda widget's "always show today" anchor logic (#35). */ +class AnchorTodayTest { + + private val today = LocalDate(2026, 6, 17) + private val tomorrow = LocalDate(2026, 6, 18) + + @Test + fun `disabled leaves the days untouched even when today is absent`() { + val days = listOf(AgendaDay(tomorrow, emptyList())) + assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days) + } + + @Test + fun `enabled prepends an empty today when it is missing`() { + val days = listOf(AgendaDay(tomorrow, emptyList())) + val result = anchorTodayIfMissing(days, today, enabled = true) + assertThat(result).hasSize(2) + assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList())) + assertThat(result[1]).isEqualTo(days.first()) + } + + @Test + fun `enabled anchors today into an otherwise empty list`() { + val result = anchorTodayIfMissing(emptyList(), today, enabled = true) + assertThat(result).containsExactly(AgendaDay(today, emptyList())) + } + + @Test + fun `enabled is a no-op when today already has its own day`() { + val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList())) + assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days) + } +} From 396a5610aa0831556827bf47bb90fccd190dc56b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:02:37 +0200 Subject: [PATCH 9/9] fix(agenda): localize dates and refine the range bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localize every agenda date via a new localizedDateFormatter helper that lays a field skeleton out in the locale's own order (Android best-pattern), instead of a hardcoded day-month-year layout: the range-window summary, the screen's day headers, and the widget's day headers. This also fixes the window mixing two orders (e.g. "15 Jul – Aug 13, 2026"). Refine the range bar: the banner drops the range name (it already sits on the selector button beside it) and shows just the concrete dates; the selector keeps a subtle neutral surface tint — distinct from the top-bar view switcher's secondary container so the two don't compete — and its right edge lines up with the switcher. The anchored empty-today card takes a single event row's resting corner radius. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/agenda/AgendaRange.kt | 23 +++-- .../calendula/ui/agenda/AgendaScreen.kt | 85 +++++++++---------- .../calendula/ui/common/LocaleSupport.kt | 13 +++ .../calendula/widget/agenda/AgendaWidget.kt | 8 +- 4 files changed, 73 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index 1ae2046..b4cda3f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -1,10 +1,9 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import java.time.YearMonth -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.util.Locale /** @@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when /** * The concrete span the range covers, starting at [start] through [end] - * (inclusive), for a human-readable header: - * - [AgendaRange.Day] → a single medium date ("27 Jun 2026") + * (inclusive), for a human-readable header. Both ends use the same day-month + * order so a span never mixes "15 Jul" with "Aug 13, 2026": + * - [AgendaRange.Day] → a single date ("27 Jun 2026") * - [AgendaRange.ThisMonth] → month and year ("June 2026") - * - everything else → "start – end" ("27 Jun – 3 Jul 2026") + * - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's + * year shown too only when it differs from the end's. */ fun agendaRangeWindowSummary( range: AgendaRange, @@ -100,11 +101,15 @@ fun agendaRangeWindowSummary( ): String { val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) - val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + val dayMonth = localizedDateFormatter(locale, "dMMM") + val dayMonthYear = localizedDateFormatter(locale, "dMMMy") return when (range) { - AgendaRange.Day -> medium.format(javaStart) - AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) - else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}" + AgendaRange.Day -> dayMonthYear.format(javaStart) + AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart) + else -> { + val startFmt = if (start.year == end.year) dayMonth else dayMonthYear + "${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}" + } } } 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 6d7785c..5671d5a 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 @@ -15,17 +15,17 @@ 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.layout.width 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.filled.Coffee -import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.DrawerValue +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -66,6 +66,7 @@ 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.IMPLEMENTED_VIEWS +import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position @@ -84,7 +85,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant -import java.time.format.TextStyle as JavaTextStyle import java.util.Locale private val zone = TimeZone.currentSystemDefault() @@ -172,9 +172,11 @@ fun AgendaScreen( successState?.takeIf { it.showRangeBar }?.let { s -> Row( verticalAlignment = Alignment.CenterVertically, + // end aligns the selector's right edge with the top-bar view + // switcher (its 8.dp margin + the app bar's 4.dp inset). modifier = Modifier .fillMaxWidth() - .padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), ) { AgendaRangeBanner( range = s.range, @@ -216,9 +218,11 @@ fun AgendaScreen( } /** - * A compact tonal pill showing the agenda's current range. Tapping it opens the - * range picker as a session-only override. Filled with the primary container - * while an override is active, so the temporary state is obvious. + * The agenda's current range, tapped to open the range picker as a session-only + * override. Shares the top-bar view switcher's button shape so the two read as a + * family, but stays low-emphasis — a subtle neutral surface tint rather than the + * switcher's secondary container, so it doesn't compete. Fills with the primary + * container only while an override is active, to make that temporary state clear. */ @Composable private fun AgendaRangePill( @@ -227,43 +231,34 @@ private fun AgendaRangePill( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val container = if (isOverride) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh - } - val content = if (isOverride) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - Surface( - color = container, - contentColor = content, - shape = RoundedCornerShape(50), - modifier = modifier.clickable(onClick = onClick), + FilledTonalButton( + onClick = onClick, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = if (isOverride) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = if (isOverride) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + modifier = modifier, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - ) { - Icon( - imageVector = Icons.Filled.DateRange, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = agendaRangeLabel(range), - style = MaterialTheme.typography.labelLarge, - ) - } + Text( + text = agendaRangeLabel(range), + style = MaterialTheme.typography.labelLarge, + ) } } /** - * A header naming the concrete window currently shown, e.g. "Showing all events - * for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026". + * A header naming the concrete window currently shown under a "showing …" label, + * e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name + * lives on the selector button beside it, so it isn't repeated here. */ @Composable private fun AgendaRangeBanner( @@ -280,8 +275,10 @@ private fun AgendaRangeBanner( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + // Just the concrete dates — the range's name ("Next 30 days") already + // sits on the selector button to the right, so repeating it here is noise. Text( - text = "${agendaRangeLabel(range)}, $window", + text = window, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) @@ -418,6 +415,8 @@ private fun AgendaDayHeader( @Composable private fun AgendaEmptyDayRow(onClick: () -> Unit) { Card( + // Match a single event row's resting corner radius (floret groupedShape). + shape = RoundedCornerShape(22.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp) @@ -575,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str private fun formatAgendaDate(date: LocalDate): String { val locale = Locale.getDefault() val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) - val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) - val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) - return "$weekday, ${date.day}. $monthName ${date.year}" + // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" + // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. + return localizedDateFormatter(locale, "EEEdMMMy").format(java) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt index c3a72a4..8fc28ef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalConfiguration import androidx.core.os.ConfigurationCompat +import java.time.format.DateTimeFormatter import java.util.Locale /** @@ -18,3 +19,15 @@ fun currentLocale(): Locale { ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() } } + +/** + * A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order + * (via Android's best-pattern matching), so dates read naturally per locale + * instead of a hardcoded day-month-year order. [skeleton] lists the wanted + * fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too). + */ +fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter = + DateTimeFormatter.ofPattern( + android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton), + locale, + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index ca2ce11..b0c0604 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -55,6 +55,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme @@ -67,7 +68,6 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant -import java.time.format.TextStyle as JavaTextStyle import java.util.Locale /** @@ -399,9 +399,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): } val locale = Locale.getDefault() val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) - val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) - val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) - val formatted = "$weekday, ${date.day} $monthName" + // Weekday + date in the locale's own field order (compact: no year), rather + // than a hardcoded day-month layout. + val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java) return if (relative != null) "$relative · $formatted" else formatted }