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
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 27b8bf3..c18fd34 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -384,6 +384,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 4fd47bb..52a99b7 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?