Share the detail screen's duplicate, share and delete actions (#141)

This commit is contained in:
2026-10-09 10:54:39 +02:00
parent 4747926e27
commit a48becefc0
4 changed files with 201 additions and 154 deletions
@@ -265,3 +265,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart) add(EventFormProblem.RecurrenceEndsBeforeStart)
} }
} }
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -0,0 +1,170 @@
package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pending?.invoke()
pending = null
}
return { action ->
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
action()
} else {
pending = action
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.ui.detail package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -41,7 +37,6 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -67,7 +61,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
@@ -90,13 +83,15 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
@@ -104,7 +99,6 @@ import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
@@ -162,43 +156,15 @@ fun EventDetailScreen(
val onShareClick = { val onShareClick = {
scope.launch { scope.launch {
val uri = viewModel.shareUri() val uri = viewModel.shareUri()
val sent = uri != null && runCatching { val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage) if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
} }
Unit Unit
} }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action — // upgrade in place and continues straight into the tapped action.
// edit, delete, or duplicate — held here until the result comes back. val requireWrite = rememberWriteGate()
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onDeleteClick = { requireWrite { showDeleteDialog = true } } val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) } val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the // Duplicate reads the loaded detail into a create form and hands it up; the
@@ -322,70 +288,6 @@ fun EventDetailScreen(
} }
} }
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable @Composable
private fun EventDetailContent( private fun EventDetailContent(
state: EventDetailUiState.Success, state: EventDetailUiState.Success,
@@ -11,12 +11,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.ui.common.deleteEvent
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting _deleteState.value = DeleteUiState.Deleting
_deleteState.value = try { _deleteState.value = try {
when (scope) { repository.deleteEvent(target.eventId, target.beginMillis, scope)
RecurringWriteScope.AllEvents ->
repository.deleteEvent(target.eventId)
RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted DeleteUiState.Deleted
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor(
*/ */
suspend fun shareUri(): Uri? { suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching { return withContext(io) { icsExporter.stageEventShare(detail) }
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
} }
/** /**
* Build a create form seeded from the open event so the user can save a * A create form seeded from the open event, to save a copy as a new event
* copy as a new, independent event (#52). Returns null when nothing is * (#52) — see [toDuplicateForm]. Null when nothing is loaded.
* 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? { fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
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 { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
} }
} }
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}