From 79e74d9995220fcadb0693fee6098bb64dae2b08 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:10:23 +0200 Subject: [PATCH 1/2] 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? -- 2.49.1 From a19772e3a786499a3181bec2dfba31b9bf0e6490 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 19:11:34 +0200 Subject: [PATCH 2/2] 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 -- 2.49.1