From 3c9767387b480b00c30c56cb6d447ce584748eba Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 8 Jul 2026 18:02:13 +0200 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 09/19] 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 } From 79e74d9995220fcadb0693fee6098bb64dae2b08 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:10:23 +0200 Subject: [PATCH 10/19] feat(detail): duplicate an event as a new one (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Duplicate action to the event detail top bar that opens the shared event form seeded with a copy of the event as a new, unsaved event, so a non-recurring event can be re-created with just the day and time changed instead of re-entering every field. The copy reuses the existing prefilled-create overlay (createEvent), so it becomes an independent event with the default reminder applied. Recurrence is dropped — a duplicate is a single event; the edit form still exposes a recurrence picker for anyone who wants a series. The occurrence's own times carry over unchanged. Duplicate is offered for any loaded event, including read-only ones (WebCal, birthdays): the source calendar is kept only when it's writable, otherwise the copy resolves to the first writable calendar. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/CalendarHost.kt | 8 +++ .../calendula/ui/detail/EventDetailScreen.kt | 50 ++++++++++++------- .../ui/detail/EventDetailViewModel.kt | 28 +++++++++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index a70951b..72ef6d2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -368,6 +368,14 @@ fun CalendarHost( heldEditKey = key editKey = key }, + onDuplicate = { form -> + // Reuse the prefilled-create overlay: a duplicate is a + // fresh event, so it applies the default reminder like an + // in-app new event (#52). + importFormSource = ImportSource.Insert + importForm = form + detailKey = null + }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 36afbdc..8c694a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Notifications @@ -87,6 +88,7 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship import de.jeanlucmakiola.calendula.domain.AttendeeStatus import de.jeanlucmakiola.calendula.domain.AttendeeType import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope @@ -116,7 +118,10 @@ import kotlin.time.Instant * top-bar arrow both return to the calendar. Events in writable calendars can * be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared * event form for this occurrence — for recurring events the form asks how - * far the change reaches when saving. + * far the change reaches when saving. [onDuplicate] opens the same form + * seeded with a copy of this event as a new, unsaved event (#52) — offered + * for any event, including read-only ones, since the copy lands in an + * editable calendar. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -126,6 +131,7 @@ fun EventDetailScreen( endMillis: Long, onBack: () -> Unit, onEdit: () -> Unit, + onDuplicate: (EventForm) -> Unit, viewModel: EventDetailViewModel = hiltViewModel(), ) { LaunchedEffect(eventId, beginMillis, endMillis) { @@ -160,15 +166,14 @@ fun EventDetailScreen( } // v1.0 installs only hold READ_CALENDAR; the first write asks for the - // upgrade in place. Granting continues straight into the tapped action. - var pendingEdit by remember { mutableStateOf(false) } + // upgrade in place. Granting continues straight into the tapped action — + // edit, delete, or duplicate — held here until the result comes back. + var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) } val writePermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) { - if (pendingEdit) onEdit() else showDeleteDialog = true - } - pendingEdit = false + if (granted) pendingWrite?.invoke() + pendingWrite = null } val hasWritePermission = { ContextCompat.checkSelfPermission( @@ -176,21 +181,20 @@ fun EventDetailScreen( Manifest.permission.WRITE_CALENDAR, ) == PackageManager.PERMISSION_GRANTED } - val onDeleteClick = { + val requireWrite: (() -> Unit) -> Unit = { action -> if (hasWritePermission()) { - showDeleteDialog = true + action() } else { - pendingEdit = false + pendingWrite = action writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) } } - val onEditClick = { - if (hasWritePermission()) { - onEdit() - } else { - pendingEdit = true - writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) - } + val onDeleteClick = { requireWrite { showDeleteDialog = true } } + val onEditClick = { requireWrite(onEdit) } + // Duplicate reads the loaded detail into a create form and hands it up; the + // create still needs WRITE_CALENDAR even when the source is read-only. + val onDuplicateClick = { + requireWrite { viewModel.duplicateForm()?.let(onDuplicate) } } val deleteFailedMessage = stringResource(R.string.event_delete_failed) @@ -229,7 +233,8 @@ fun EventDetailScreen( }, actions = { val s = state - // Share works for any loaded event — it only reads the event. + // Share and duplicate work for any loaded event — both only + // read it; the duplicate is created into a writable calendar. if (s is EventDetailUiState.Success) { IconButton(onClick = onShareClick) { Icon( @@ -237,6 +242,15 @@ fun EventDetailScreen( contentDescription = stringResource(R.string.event_detail_share), ) } + IconButton( + onClick = onDuplicateClick, + enabled = deleteState != DeleteUiState.Deleting, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.event_detail_duplicate), + ) + } } // Edit/delete need a writable calendar — WebCal subscriptions, // birthday calendars etc. are read-only at the provider level. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 4a4aebd..353a9ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent +import de.jeanlucmakiola.calendula.domain.toEditForm import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import kotlin.time.Clock @@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Instant import javax.inject.Inject @@ -138,6 +141,31 @@ class EventDetailViewModel @Inject constructor( }.getOrNull() } + /** + * Build a create form seeded from the open event so the user can save a + * copy as a new, independent event (#52). Returns null when nothing is + * loaded. + * + * The recurrence is dropped — a duplicate is a single event; anyone who + * wants a series can re-add one in the edit form that opens. The source + * calendar is kept only when it's writable, otherwise the id is cleared so + * the create resolves to the last-used/first-writable calendar — which lets + * a read-only event (a WebCal subscription, a birthday) be copied into an + * editable calendar. The occurrence's own times carry over unchanged. + */ + fun duplicateForm(): EventForm? { + val loaded = state.value as? EventDetailUiState.Success ?: return null + val detail = loaded.detail + return detail.toEditForm( + beginMillis = detail.instance.start.toEpochMilliseconds(), + endMillis = detail.instance.end.toEpochMilliseconds(), + zone = TimeZone.currentSystemDefault(), + ).copy( + rrule = null, + calendarId = detail.instance.calendarId.takeIf { loaded.canModify }, + ) + } + private suspend fun loadDetail(target: Target): EventDetailUiState = try { val detail = repository.eventDetail(target.eventId) // The Events row holds the series start; replace it with this diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ade055d..4e4bbb1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Edit Delete Share + Duplicate Share event Couldn\'t share this event. Delete event? From a19772e3a786499a3181bec2dfba31b9bf0e6490 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:11:34 +0200 Subject: [PATCH 11/19] docs(changelog): note the duplicate-event action (#52) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce551f0..f578ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 under today's header a small "No more events today" card appears — so the first events you see are clearly today's rather than a future day's. Turn it off to keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]). +- Duplicate an event. The event details now carry a **Duplicate** action that + opens the editor pre-filled with a copy of the event as a new, unsaved one, so + a one-off like a shift or an appointment can be recreated by just changing the + day and time instead of re-typing every field. The copy keeps the original's + time, and its title, location, notes, colour, guests and reminders come along; + it's saved as its own single event (any repeat is left off — add one in the + editor if you want it). Duplicate works from read-only calendars too, dropping + the copy into a writable one. Thanks to @internet-rando for the suggestion + ([#52]). ### Fixed - Reminders for events on another day no longer read as if they were today. A @@ -957,3 +966,4 @@ automatically, with zero telemetry and no internet permission. [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 +[#52]: https://codeberg.org/jlmakiola/calendula/issues/52 From e73148dc6cfb9f2fc44185baeb0aa064419ad6ec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:15:51 +0200 Subject: [PATCH 12/19] fix(widget): keep the empty-today line plain, not a card The agenda widget's event rows are plain (a colour stripe + text, no card), so the rounded "No more events today" surface looked out of place. Render it as a muted line indented to the event titles instead, matching the widget's style. The in-app agenda keeps its coffee-cup card, where event rows are cards too. Co-Authored-By: Claude Opus 4.8 --- .../calendula/widget/agenda/AgendaWidget.kt | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) 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 b0c0604..9ea5311 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 @@ -287,41 +287,25 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { } /** - * A card under an anchored, event-less today (#35) — echoing the widget's - * full-empty message ("You're all caught up" here becomes "No more events - * today") in a rounded surface so today's slot never looks like a bare header. + * "No more events today" under an anchored, event-less today (#35). Kept plain — + * a muted line indented to the event titles — since the widget's event rows are + * plain too (a stripe + text, not cards), so a card here would look out of place. */ @Composable private fun PlaceholderRow(date: LocalDate) { val context = androidx.glance.LocalContext.current - Box( + Text( + text = context.getString(R.string.agenda_no_more_today), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp) + .padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), ), ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = GlanceModifier - .fillMaxWidth() - .background(GlanceTheme.colors.surfaceVariant) - .cornerRadius(16.dp) - .padding(vertical = 18.dp, horizontal = 12.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = context.getString(R.string.agenda_no_more_today), - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 14.sp, - ), - ) - } - } + ) } @Composable From 5377a2b4664b583d0f0b9c9566637edd8f3da1cc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:15:51 +0200 Subject: [PATCH 13/19] fix(calendar): align the week-number gutter with the top bar hamburger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The month grid was inset 4dp while its weekday header (and the loading grid) used 8dp, so the week-number column — and the day cells under their labels — sat 4dp left of where they should. Bring the grid to 8dp: the gutter centre now lands on the hamburger (4dp bar inset + 24dp half icon), and day cells sit under their weekday labels. The week view's header badge and hour labels had the same drift (a 48dp edge-to- edge gutter centres its content at 24dp, not 28dp). Its top section background bleeds full-width when scrolled, so instead of insetting the whole content, give just the gutter content an 8dp start inset to centre it on the hamburger too. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/month/MonthScreen.kt | 5 ++++- .../jeanlucmakiola/calendula/ui/week/WeekScreen.kt | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ac8df8d..023a90c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -380,7 +380,10 @@ private fun MonthGrid( Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 4.dp, vertical = 4.dp), + // Match the weekday header's 8dp inset so day cells sit under their + // labels, and so the week-number gutter's centre lines up with the + // top bar's hamburger (4dp bar inset + 24dp half icon button). + .padding(horizontal = 8.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { state.weeks.forEach { week -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 1d7a2eb..57bd153 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -114,6 +114,10 @@ import kotlin.math.roundToInt private val HOUR_HEIGHT = 56.dp private val GUTTER_WIDTH = 48.dp +/** Start inset for the gutter's content (week badge + hour labels) so it centres + * on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp + * (the app bar's 4dp inset + 24dp half icon button). */ +private val GUTTER_CONTENT_START_INSET = 8.dp private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -448,9 +452,10 @@ private fun WeekDayHeader( .padding(top = 4.dp, bottom = 8.dp), ) { // Mirror the day-column layout (empty weekday line + spacer) so the - // badge lines up vertically with the date numbers. + // badge lines up vertically with the date numbers. The start inset centres + // the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET). Column( - modifier = Modifier.width(GUTTER_WIDTH), + modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET), horizontalAlignment = Alignment.CenterHorizontally, ) { Text(text = " ", style = MaterialTheme.typography.labelSmall) @@ -621,10 +626,12 @@ private fun Timeline( // soft corners are permanent at any scroll position (not just at the // day's start/end). Row(modifier = Modifier.fillMaxSize()) { - // Hour gutter (scrolls in sync with the day columns) + // Hour gutter (scrolls in sync with the day columns). Same start inset + // as the header badge so the labels sit under it and on the hamburger. Column( modifier = Modifier .width(GUTTER_WIDTH) + .padding(start = GUTTER_CONTENT_START_INSET) .fillMaxHeight() .verticalScroll(scrollState), ) { From 953ffdff97b28ed27806b1621b170ec1e5e24120 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:19:42 +0200 Subject: [PATCH 14/19] fix(day): align the hour-gutter labels with the top bar hamburger The day view's 48dp edge-to-edge hour gutter centred its labels at 24dp, 4dp left of the hamburger. Give the gutter content the same 8dp start inset as the week view so the labels centre on the hamburger (4dp bar inset + 24dp half icon). Co-Authored-By: Claude Opus 4.8 --- .../java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 12ab2b8..f802bb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -97,6 +97,10 @@ import kotlin.math.roundToInt private val HOUR_HEIGHT = 56.dp private val GUTTER_WIDTH = 48.dp +/** Start inset for the gutter's hour labels so they centre on the top bar's + * hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's + * 4dp inset + 24dp half icon button), matching the week view. */ +private val GUTTER_CONTENT_START_INSET = 8.dp private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -484,10 +488,12 @@ private fun Timeline( // static, rounded-clipped window — the content scrolls inside it, so the // soft corners are permanent at any scroll position. Row(modifier = Modifier.fillMaxSize()) { - // Hour gutter (scrolls in sync with the day column) + // Hour gutter (scrolls in sync with the day column). Start inset so the + // labels centre on the top bar hamburger, matching the week view. Column( modifier = Modifier .width(GUTTER_WIDTH) + .padding(start = GUTTER_CONTENT_START_INSET) .fillMaxHeight() .verticalScroll(scrollState), ) { From 98a48aa79598d66b6f6849f048410d2e9f09b725 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:27:20 +0200 Subject: [PATCH 15/19] docs(changelog): note the agenda date localization and gutter alignment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also correct the #35 entry — the empty-today marker is a card in the app but a plain line in the widget, so call it a "note" rather than a card. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f578ca7..f516ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Keep today at the top of the agenda. A new **Always show today** setting (Settings → Agenda, on by default) anchors today as the first entry in both the Agenda screen and its home-screen widget even once nothing is left today — - under today's header a small "No more events today" card appears — so the first + under today's header a "No more events today" note appears — so the first events you see are clearly today's rather than a future day's. Turn it off to keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]). - Duplicate an event. The event details now carry a **Duplicate** action that @@ -52,6 +52,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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]). +- Agenda dates now read in your locale's format. The agenda's range bar and its + day headers used a fixed day-month-year layout — and the range span even mixed + two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's + conventions. Dates across the agenda and its widget now use your locale's own + field order, matching the rest of the app. The range bar also no longer repeats + the range's name from the selector button beside it, showing just the dates. +- Calendar gutters line up with the menu button. The Month view's week-number + column, and the Week and Day views' hour labels, sat a few pixels left of the + hamburger menu above them; they now line up with it. In Month view the day + cells also sit squarely under their weekday letters. ## [2.14.1] — 2026-07-13 From 3d5996211287e300e4af4b01abd2789db218b81f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:40:10 +0200 Subject: [PATCH 16/19] i18n: offer French and Polish in the language picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both landed as community translations through Weblate (French ~33%, Polish ~57%), above the bar already shipped for zh-CN. Add them to locales_config.xml — the single source of truth for the in-app language picker and Android's per-app language settings. Co-Authored-By: Claude Opus 4.8 --- app/src/main/res/xml/locales_config.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 0c7b98a..385bf1f 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -11,6 +11,8 @@ + + From 227973837158611e61c6a581dc30b8009911e101 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:42:19 +0200 Subject: [PATCH 17/19] docs(changelog): note the new French and Polish translations Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f516ca3..2240ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 editor if you want it). Duplicate works from read-only calendars too, dropping the copy into a writable one. Thanks to @internet-rando for the suggestion ([#52]). +- French and Polish, in early form. Calendula has started speaking French and + Polish, both contributed as community translations through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + They are partway there, so untranslated parts still show in English until they + fill out — you can already pick either under Settings → Language or in Android's + per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran + (Polish) for getting them started; help finishing them is very welcome. ### Fixed - Reminders for events on another day no longer read as if they were today. A From 50510a23da3dd74d7319d983b888b2111aad507c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 20:26:34 +0200 Subject: [PATCH 18/19] feat(colors): optional raw calendar colours + readable title contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Soften calendar colours" setting (Settings → Design, default on). Turning it off paints calendar and event colours raw, exactly as the sync source (DAVx5/CalDAV) publishes them, instead of the theme-fitting pastels (#36). Event/calendar colours now flow through shared eventFill()/eventInk() helpers gated by a LocalSoftenColors composition local (widgets read the pref directly). Event titles pick black or white text by the fill's WCAG relative luminance, so a dark colour stays legible whether softened or raw (#21) — previously always near-black. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++++++ .../jeanlucmakiola/calendula/MainActivity.kt | 2 + .../calendula/data/prefs/SettingsPrefs.kt | 16 +++++++ .../calendula/ui/agenda/AgendaScreen.kt | 6 ++- .../calendula/ui/calendars/CalendarsScreen.kt | 6 ++- .../calendula/ui/common/CalendarColors.kt | 9 ++-- .../calendula/ui/common/ColorSwatchRow.kt | 11 +++-- .../calendula/ui/common/EventColors.kt | 46 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 18 +++++--- .../calendula/ui/detail/EventDetailScreen.kt | 5 +- .../calendula/ui/edit/EventEditScreen.kt | 8 ++-- .../calendula/ui/month/MonthScreen.kt | 15 ++++-- .../calendula/ui/search/SearchScreen.kt | 6 ++- .../calendula/ui/settings/SettingsScreen.kt | 14 +++++- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 35 ++++++++++++-- .../calendula/ui/week/WeekScreen.kt | 18 +++++--- .../calendula/widget/WidgetData.kt | 4 ++ .../calendula/widget/agenda/AgendaWidget.kt | 13 ++++-- .../calendula/widget/month/MonthWidget.kt | 33 +++++++------ app/src/main/res/values/strings.xml | 2 + 21 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2240ec4..cf7261a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Show raw calendar colours. Calendula normally softens each calendar and event + colour toward a theme-fitting pastel so harsh sync colours read well on both + light and dark; a new **Soften calendar colours** setting (Settings → Design, + on by default) lets you turn that off and paint the exact colours your calendar + source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to + @leonp5 for the report ([#36]). +- Readable titles on dark event colours. An event bar's title now shows in white + on a dark colour and near-black on a light one, chosen automatically from the + colour's brightness, so a deep blue or purple event is legible at a glance in + the busy Week and Month views instead of dark-on-dark. This applies whether or + not colours are softened. Thanks to @ptab for the suggestion ([#21]). - 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 @@ -971,10 +982,12 @@ automatically, with zero telemetry and no internet permission. [#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 +[#21]: https://codeberg.org/jlmakiola/calendula/issues/21 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 +[#36]: https://codeberg.org/jlmakiola/calendula/issues/36 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39 [#35]: https://codeberg.org/jlmakiola/calendula/issues/35 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 99ad92a..1177165 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.common.CalendarView @@ -140,6 +141,7 @@ class MainActivity : AppCompatActivity() { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, LocalShowHourLines provides settings.showHourLines, + LocalSoftenColors provides settings.softenColors, ) { RootScreen( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 7bed479..cb4e610 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -114,6 +114,21 @@ class SettingsPrefs @Inject constructor( store.edit { it[DYNAMIC_COLOR_KEY] = enabled } } + /** + * Whether raw provider colours are softened toward theme-fitting pastels + * before display (issue #36). Defaults to ON — the historical look, which + * caps harsh sync colours and pins brightness so entries read on both + * themes. Turning it off paints the calendar/event colour exactly as the + * sync source (DAVx5/CalDAV) publishes it. + */ + val softenCalendarColors: Flow = store.data.map { prefs -> + prefs[SOFTEN_COLORS_KEY] ?: true + } + + suspend fun setSoftenCalendarColors(enabled: Boolean) { + store.edit { it[SOFTEN_COLORS_KEY] = enabled } + } + /** * Custom-font tokens per Material typeface role (issue #19). Stored as opaque * strings — "system", "custom", or a bundled font's token — resolved to a @@ -679,6 +694,7 @@ class SettingsPrefs @Inject constructor( companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") + internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors") internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font") internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font") internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp") 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 5671d5a..51aad58 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 @@ -59,6 +59,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer @@ -72,7 +74,6 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -454,6 +455,7 @@ private fun AgendaEventRow( onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, @@ -466,7 +468,7 @@ private fun AgendaEventRow( modifier = Modifier .size(width = 6.dp, height = 36.dp) .clip(RoundedCornerShape(3.dp)) - .background(pastelize(event.color, dark)), + .background(eventFill(event.color, dark, soften)), ) }, onClick = onClick, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 06be705..1c99d16 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage @@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.floret.components.pastelize import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ @@ -565,6 +566,7 @@ private fun CalendarEditor( var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var confirmDelete by remember { mutableStateOf(false) } val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current Scaffold( modifier = Modifier @@ -624,7 +626,7 @@ private fun CalendarEditor( .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) { + EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) { InlineTextField( value = name, onValueChange = { name = it }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt index 9a64b40..b6b889f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt @@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import de.jeanlucmakiola.floret.components.pastelize /** * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted - * in the calendar's (pastelised) colour. Shared by the calendar manager and the - * visibility filter so they read identically. + * in the calendar's colour — softened to a pastel, or raw when the softener is + * off (issue #36). Shared by the calendar manager and the visibility filter so + * they read identically. */ @Composable fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current Box( modifier = modifier .size(40.dp) @@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { Icon( Icons.Filled.CalendarMonth, contentDescription = null, - tint = pastelize(color, dark), + tint = eventFill(color, dark, soften), modifier = Modifier.size(22.dp), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt index d0e9a9a..7449e47 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ColorSwatchRow.kt @@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import de.jeanlucmakiola.floret.components.pastelize /** * A wrapping row of round colour swatches; the one matching [selected] is * ringed and checked. Shared by the calendar editor and the event-colour * picker so both pick a colour the same way. Swatches render through - * [pastelize] — the softened colour the app actually paints, not the raw hue. + * [eventFill] — the colour the app actually paints (softened, or raw when the + * softener is off, issue #36), not necessarily the stored hue. */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -37,16 +36,18 @@ fun ColorSwatchRow( dark: Boolean, modifier: Modifier = Modifier, ) { + val soften = LocalSoftenColors.current FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) { colors.forEach { argb -> val isSelected = argb == selected + val fill = eventFill(argb, dark, soften) Box( contentAlignment = Alignment.Center, modifier = Modifier .padding(vertical = 4.dp) .size(40.dp) .clip(CircleShape) - .background(pastelize(argb, dark)) + .background(fill) .then( if (isSelected) { Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) @@ -60,7 +61,7 @@ fun ColorSwatchRow( Icon( Icons.Default.Check, contentDescription = null, - tint = Color.Black.copy(alpha = 0.7f), + tint = eventInk(fill, alpha = 0.7f), modifier = Modifier.size(20.dp), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt new file mode 100644 index 0000000..938c337 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import de.jeanlucmakiola.floret.components.pastelize + +/** + * Whether calendar/event colours are softened toward theme-fitting pastels + * before display (issue #36). Provided app-wide from the "soften colours" + * setting; the default `true` keeps the historical look. When off, the raw + * provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV) + * and other calendar apps. Widgets live outside this composition and read the + * preference directly, then pass the flag to [eventFill] / [eventInk]. + */ +val LocalSoftenColors = staticCompositionLocalOf { true } + +/** + * Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour + * when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since + * pastelize also returns an opaque colour). + */ +fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color = + if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt()) + +/** + * Contrast ink (title text / glyph) for a filled chip painted with [eventFill]: + * white on a dark fill, near-black on a light one (issue #21). Applies to both + * softened and raw fills — a saturated hue (deep blue, purple, red) is + * perceptually dark even after softening pins its HSV value, so black text on it + * reads poorly. The choice is objective, not a tuned threshold: white wins only + * when it out-contrasts black against the fill, which (by the WCAG contrast + * ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep + * near-black text. [alpha] carries the caller's soft emphasis. + */ +fun eventInk(fill: Color, alpha: Float = 0.8f): Color { + val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER + return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha) +} + +/** + * Relative-luminance crossover where white text starts to out-contrast black. + * Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives + * `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`. + */ +private const val INK_LUMINANCE_CROSSOVER = 0.179f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index f802bb1..c1185d0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -79,7 +79,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -452,9 +454,11 @@ private fun AllDayBar( modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val soften = LocalSoftenColors.current + val fill = eventFill(event.color, dark, soften) Box( modifier = modifier - .background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) + .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -465,7 +469,7 @@ private fun AllDayBar( style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.8f), + color = eventInk(fill), ) } } @@ -616,9 +620,11 @@ private fun EventBlock( val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) val showTime = block.endMin - block.startMin >= 45 + val soften = LocalSoftenColors.current + val fill = eventFill(block.event.color, dark, soften) Box( modifier = modifier - .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) + .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 4.dp, vertical = 2.dp) .semantics { contentDescription = "$title, $timeLabel" }, @@ -629,7 +635,7 @@ private fun EventBlock( style = MaterialTheme.typography.labelMedium, maxLines = if (showTime) 1 else 2, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.85f), + color = eventInk(fill, alpha = 0.85f), ) if (showTime) { Text( @@ -637,7 +643,7 @@ private fun EventBlock( style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.6f), + color = eventInk(fill, alpha = 0.6f), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 8c694a2..d4073a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -95,11 +95,12 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import kotlinx.coroutines.launch @@ -381,7 +382,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi val instance = detail.instance val dark = isSystemInDarkTheme() val locale = currentDetailLocale() - val accent = pastelize(instance.color, dark) + val accent = eventFill(instance.color, dark, LocalSoftenColors.current) Column( modifier = modifier 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 838930f..301cea9 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 @@ -126,6 +126,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon @@ -146,7 +148,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.common.recurrenceText import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -541,7 +542,8 @@ private fun EventEditContent( val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } // The accent ties the form to the detail screen's design language: the // bar under the title takes the target calendar's colour. - val accent = selectedCalendar?.let { pastelize(it.color, dark) } + val soften = LocalSoftenColors.current + val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp @@ -893,7 +895,7 @@ private fun EventEditContent( icon = Icons.Default.Palette, iconContentDescription = stringResource(R.string.event_edit_color), iconTint = if (colorSupported && swatch != null) { - pastelize(swatch, dark) + eventFill(swatch, dark, soften) } else { MaterialTheme.colorScheme.onSurfaceVariant }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 023a90c..100c71f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -71,6 +71,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition @@ -79,7 +82,6 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek @@ -630,7 +632,7 @@ private fun DayNumberCell( } } -/** A filled event pill/bar — pastelized fill, title clipped to one line. */ +/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( event: de.jeanlucmakiola.calendula.domain.EventInstance, @@ -642,6 +644,8 @@ private fun MonthBar( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) + val soften = LocalSoftenColors.current + val fill = eventFill(event.color, dark, soften) val shape = RoundedCornerShape( topStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp, @@ -650,7 +654,7 @@ private fun MonthBar( ) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(pastelize(event.color, dark), shape) + .background(fill, shape) .padding(horizontal = 4.dp) .semantics { contentDescription = title }, contentAlignment = Alignment.CenterStart, @@ -660,7 +664,7 @@ private fun MonthBar( style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.8f), + color = eventInk(fill), ) } } @@ -673,6 +677,7 @@ private fun OverflowDots( dark: Boolean, modifier: Modifier = Modifier, ) { + val soften = LocalSoftenColors.current Row( modifier = modifier.height(EVENT_ROW_HEIGHT), horizontalArrangement = Arrangement.spacedBy(2.dp), @@ -682,7 +687,7 @@ private fun OverflowDots( Box( modifier = Modifier .size(6.dp) - .background(pastelize(argb, dark), CircleShape), + .background(eventFill(argb, dark, soften), CircleShape), ) } if (extra > 0) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 738fd8d..9eec23e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -55,9 +55,10 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf import java.time.Instant as JavaInstant import java.time.ZoneId @@ -192,6 +193,7 @@ private fun SearchResultRow( onClick: () -> Unit, ) { val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current GroupedRow( modifier = modifier, title = event.title, @@ -203,7 +205,7 @@ private fun SearchResultRow( modifier = Modifier .size(width = 6.dp, height = 36.dp) .clip(RoundedCornerShape(3.dp)) - .background(pastelize(event.color, dark)), + .background(eventFill(event.color, dark, soften)), ) }, onClick = onClick, 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 c3a5f10..19f798b 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 @@ -517,7 +517,7 @@ private fun AppearanceScreen( } else { stringResource(R.string.settings_dynamic_color_unavailable) }, - position = Position.Bottom, + position = Position.Middle, trailing = { Switch( checked = state.dynamicColor, @@ -531,6 +531,18 @@ private fun AppearanceScreen( null }, ) + GroupedRow( + title = stringResource(R.string.settings_soften_colors), + summary = stringResource(R.string.settings_soften_colors_summary), + position = Position.Bottom, + trailing = { + Switch( + checked = state.softenColors, + onCheckedChange = viewModel::setSoftenColors, + ) + }, + onClick = { viewModel.setSoftenColors(!state.softenColors) }, + ) Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index b2f4bf0..1bbc6c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -24,6 +24,8 @@ data class SettingsUiState( val themeMode: ThemeMode = ThemeMode.SYSTEM, val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, + /** Whether raw provider colours are softened to theme-fitting pastels (#36). */ + val softenColors: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.Auto, /** Clock convention for time labels (v2.11). AUTO follows the system setting. */ val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 17cbfce..22d1a6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -123,14 +123,17 @@ class SettingsViewModel @Inject constructor( prefs.showHourLines, prefs.showWeekNumbers, prefs.agendaShowToday, - ::Triple, - ), + prefs.softenCalendarColors, + ) { hourLines, weekNumbers, showToday, soften -> + DisplayToggles(hourLines, weekNumbers, showToday, soften) + }, ) { view, screenRange, widgetRange, timeFormat, toggles -> ViewSettings( view, screenRange, widgetRange, timeFormat, - showHourLines = toggles.first, - showWeekNumbers = toggles.second, - agendaShowToday = toggles.third, + showHourLines = toggles.showHourLines, + showWeekNumbers = toggles.showWeekNumbers, + agendaShowToday = toggles.agendaShowToday, + softenColors = toggles.softenColors, ) }, combine( @@ -155,6 +158,7 @@ class SettingsViewModel @Inject constructor( showHourLines = views.showHourLines, showWeekNumbers = views.showWeekNumbers, agendaShowToday = views.agendaShowToday, + softenColors = views.softenColors, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, @@ -229,6 +233,14 @@ class SettingsViewModel @Inject constructor( val showHourLines: Boolean, val showWeekNumbers: Boolean, val agendaShowToday: Boolean, + val softenColors: Boolean, + ) + + private data class DisplayToggles( + val showHourLines: Boolean, + val showWeekNumbers: Boolean, + val agendaShowToday: Boolean, + val softenColors: Boolean, ) private data class MiscSettings( @@ -341,6 +353,19 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setDynamicColor(enabled) } } + fun setSoftenColors(enabled: Boolean) { + viewModelScope.launch { + prefs.setSoftenCalendarColors(enabled) + // Both widgets paint event colours through the same softener, so a + // change has to redraw them too (they read the flag in their data + // preamble, like is24Hour). + widgetRefreshMutex.withLock { + AgendaWidget().updateAll(appContext) + MonthWidget().updateAll(appContext) + } + } + } + fun setFont(role: FontRole, token: String) { viewModelScope.launch { when (role) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 57bd153..251e3b4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -84,6 +84,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill @@ -98,7 +101,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -589,9 +591,11 @@ private fun AllDayBar( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) + val soften = LocalSoftenColors.current + val fill = eventFill(event.color, dark, soften) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) + .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -602,7 +606,7 @@ private fun AllDayBar( style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.8f), + color = eventInk(fill), ) } } @@ -781,9 +785,11 @@ private fun EventBlock( val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) + val soften = LocalSoftenColors.current + val fill = eventFill(block.event.color, dark, soften) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) + .background(fill, RoundedCornerShape(4.dp)) .clickable(onClick = onClick) .padding(horizontal = 4.dp, vertical = 2.dp) .semantics { contentDescription = "$title, $timeLabel" }, @@ -794,7 +800,7 @@ private fun EventBlock( style = MaterialTheme.typography.labelMedium, maxLines = titleMaxLines, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.85f), + color = eventInk(fill, alpha = 0.85f), ) if (showTime) { Text( @@ -802,7 +808,7 @@ private fun EventBlock( style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = Color.Black.copy(alpha = 0.6f), + color = eventInk(fill, alpha = 0.6f), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index a1b06be..0d4f8c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -60,6 +60,8 @@ sealed interface AgendaWidgetData { val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, + /** Whether event colours are softened to pastels, or shown raw (#36). */ + val soften: Boolean, /** First day of the week, for the calendar-aligned "this week" range. */ val weekStart: DayOfWeek, /** Saved range pref — the fallback when an instance has no Glance state yet. */ @@ -132,10 +134,12 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { val instances = ep.calendarRepository().instances(window).first() val is24Hour = prefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) + val soften = prefs.softenCalendarColors.first() return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, instances, zone), is24Hour = is24Hour, + soften = soften, weekStart = weekStart, savedRange = savedRange, savedPastDisplay = savedPastDisplay, 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 9ea5311..039b4dc 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 @@ -56,7 +56,7 @@ 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.ui.common.eventFill import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData @@ -197,6 +197,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { is AgendaRow.Event -> EventRow( event = row.event, dark = dark, + soften = data.soften, is24Hour = data.is24Hour, dimmed = pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now), @@ -309,12 +310,18 @@ private fun PlaceholderRow(date: LocalDate) { } @Composable -private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) { +private fun EventRow( + event: EventInstance, + dark: Boolean, + soften: Boolean, + is24Hour: Boolean, + dimmed: Boolean, +) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } // Glance has no generic alpha modifier, so dim by fading the colour stripe and // dropping both text lines to the lower-emphasis on-surface-variant tone. - val stripeColor = pastelize(event.color, dark).let { + val stripeColor = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it } val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index f9fe54a..68b788a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.intPreferencesKey @@ -50,14 +49,17 @@ import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.MonthWidgetSource import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.widgetEntryPoint +import kotlinx.coroutines.flow.first import androidx.compose.ui.unit.Dp import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -76,9 +78,6 @@ private val LANE_HEIGHT = 14.dp private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp -/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */ -private val EventInk = ColorProvider(Color(0xDE000000)) - private fun currentMonthIndex(zone: TimeZone): Int { val t = today(zone) return t.year * 12 + t.month.ordinal @@ -107,9 +106,12 @@ class MonthWidget : GlanceAppWidget() { val source = context.loadMonthWidgetSource() val dark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + // Read fresh (not through the cached source) so toggling the softener + // redraws with the new choice; it's one cheap DataStore read. + val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first() provideContent { CalendulaGlanceTheme { - MonthWidgetBody(source = source, dark = dark) + MonthWidgetBody(source = source, dark = dark, soften = soften) } } } @@ -140,7 +142,7 @@ class ResetMonthAction : ActionCallback { } @Composable -private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) { +private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) { Column( modifier = GlanceModifier .fillMaxSize() @@ -169,6 +171,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) { currentMonth = ym.month, today = source.today, dark = dark, + soften = soften, colW = colW, modifier = GlanceModifier.defaultWeight(), ) @@ -278,6 +281,7 @@ private fun WeekRow( currentMonth: Month, today: LocalDate, dark: Boolean, + soften: Boolean, colW: Dp, modifier: GlanceModifier, ) { @@ -297,7 +301,7 @@ private fun WeekRow( // One lane row per event row. A multi-day span is a single Box spanning // its columns (colW * n) so it's connected with no seam and rounded ends. repeat(MAX_LANES) { lane -> - LaneRow(week = week, lane = lane, dark = dark, colW = colW) + LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) Spacer(GlanceModifier.height(1.dp)) } OverflowRow(week = week, colW = colW) @@ -346,7 +350,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: } @Composable -private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { +private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { var col = 0 @@ -354,12 +358,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } if (span != null) { val cols = span.endCol - col + 1 - SpanBar(event = span.event, dark = dark, width = colW * cols) + SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols) col = span.endCol + 1 } else { val timed = timedEventAt(week, lane, col, week.days[col]) if (timed != null) { - SpanBar(event = timed, dark = dark, width = colW) + SpanBar(event = timed, dark = dark, soften = soften, width = colW) } else { // Empty lane cell: a tap opens that day, so blank space in a // day column is a day-open target just like the number is. @@ -378,8 +382,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { /** A single connected, rounded event bar [width] wide with its clipped title. */ @Composable -private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { +private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) { val context = LocalContext.current + val fill = eventFill(event.color, dark, soften) Box( modifier = GlanceModifier .width(width) @@ -402,13 +407,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { modifier = GlanceModifier .fillMaxSize() .cornerRadius(4.dp) - .background(pastelize(event.color, dark)), + .background(fill), contentAlignment = Alignment.CenterStart, ) { Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, - style = TextStyle(color = EventInk, fontSize = 9.sp), + style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp), modifier = GlanceModifier.padding(horizontal = 3.dp), ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52a99b7..06f995d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,8 @@ Default view Dynamic colour Requires Android 12 or newer + Soften calendar colours + Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source. Headings font Body font System default From 6519cbca799b1ed698ddc5317428892a2256ef75 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 20:34:52 +0200 Subject: [PATCH 19/19] release: cut 2.15.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Unreleased changelog under [2.15.0] — 2026-07-15, bump versionName/versionCode to 2.15.0/21500, and sync the F-Droid per-version changelog. Milestone 2.15.0 (all integrated): #21 #35 #36 #39 #40 #46 #52. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- app/build.gradle.kts | 4 +- .../android/en-US/changelogs/21500.txt | 74 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21500.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7261a..6c4de5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.15.0] — 2026-07-15 ### Added - Show raw calendar colours. Calendula normally softens each calendar and event diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f82ef9..b058653 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21401 - versionName = "2.14.1" + versionCode = 21500 + versionName = "2.15.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21500.txt b/fastlane/metadata/android/en-US/changelogs/21500.txt new file mode 100644 index 0000000..b9d6ee0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21500.txt @@ -0,0 +1,74 @@ +### Added +- Show raw calendar colours. Calendula normally softens each calendar and event + colour toward a theme-fitting pastel so harsh sync colours read well on both + light and dark; a new **Soften calendar colours** setting (Settings → Design, + on by default) lets you turn that off and paint the exact colours your calendar + source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to + @leonp5 for the report ([#36]). +- Readable titles on dark event colours. An event bar's title now shows in white + on a dark colour and near-black on a light one, chosen automatically from the + colour's brightness, so a deep blue or purple event is legible at a glance in + the busy Week and Month views instead of dark-on-dark. This applies whether or + not colours are softened. Thanks to @ptab for the suggestion ([#21]). +- 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. + 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]). +- Open an event straight into the edit form from another app. Calendula already + answered the "new event" and "open this event" hand-offs from other apps and + widgets; it now also answers the "edit this event" one, so an assistant, task + app, or widget can send an existing event to Calendula and land on its edit + screen rather than the read-only details. A hand-off with no event attached + opens the same prefilled create form as "new event". Calendula also recognises + a couple more file labels the same calendar data arrives under (`.vcs` + vCalendar files and the `application/ics` type), so opening or sharing those + into Calendula works too. +- Keep today at the top of the agenda. A new **Always show today** setting + (Settings → Agenda, on by default) anchors today as the first entry in both the + Agenda screen and its home-screen widget even once nothing is left today — + under today's header a "No more events today" note appears — so the first + events you see are clearly today's rather than a future day's. Turn it off to + keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]). +- Duplicate an event. The event details now carry a **Duplicate** action that + opens the editor pre-filled with a copy of the event as a new, unsaved one, so + a one-off like a shift or an appointment can be recreated by just changing the + day and time instead of re-typing every field. The copy keeps the original's + time, and its title, location, notes, colour, guests and reminders come along; + it's saved as its own single event (any repeat is left off — add one in the + editor if you want it). Duplicate works from read-only calendars too, dropping + the copy into a writable one. Thanks to @internet-rando for the suggestion + ([#52]). +- French and Polish, in early form. Calendula has started speaking French and + Polish, both contributed as community translations through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + They are partway there, so untranslated parts still show in English until they + fill out — you can already pick either under Settings → Language or in Android's + per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran + (Polish) for getting them started; help finishing them is very welcome. + +### 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]). +- Agenda dates now read in your locale's format. The agenda's range bar and its + day headers used a fixed day-month-year layout — and the range span even mixed + two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's + conventions. Dates across the agenda and its widget now use your locale's own + field order, matching the rest of the app. The range bar also no longer repeats + the range's name from the selector button beside it, showing just the dates. +- Calendar gutters line up with the menu button. The Month view's week-number + column, and the Week and Day views' hour labels, sat a few pixels left of the + hamburger menu above them; they now line up with it. In Month view the day + cells also sit squarely under their weekday letters. +