From a48becefc06ed0cc89413b1e91dabb3e106310fa Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 10:54:39 +0200 Subject: [PATCH] Share the detail screen's duplicate, share and delete actions (#141) --- .../calendula/domain/EventForm.kt | 17 ++ .../calendula/ui/common/EventActions.kt | 170 ++++++++++++++++++ .../calendula/ui/detail/EventDetailScreen.kt | 110 +----------- .../ui/detail/EventDetailViewModel.kt | 58 +----- 4 files changed, 201 insertions(+), 154 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3d0fde71..5617c206 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -265,3 +265,20 @@ fun EventForm.problems(): Set = buildSet { 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 }, + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt new file mode 100644 index 00000000..500cade7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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" +} 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 5c5c7dcb..7e94e35e 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 @@ -1,13 +1,9 @@ package de.jeanlucmakiola.calendula.ui.detail -import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme 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.Schedule import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -51,7 +46,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString 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.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus -import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.floret.identity.predictiveBack 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.copyOnLongPress 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.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter @@ -162,43 +156,15 @@ fun EventDetailScreen( val onShareClick = { scope.launch { val uri = viewModel.shareUri() - val sent = uri != null && runCatching { - 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 + val sent = uri != null && context.sendEventShare(uri, shareChooserTitle) if (!sent) snackbarHostState.showSnackbar(shareFailedMessage) } Unit } // v1.0 installs only hold READ_CALENDAR; the first write asks for the - // 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) 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) - } - } + // upgrade in place and continues straight into the tapped action. + val requireWrite = rememberWriteGate() val onDeleteClick = { requireWrite { showDeleteDialog = true } } val onEditClick = { requireWrite(onEdit) } // 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 private fun EventDetailContent( state: EventDetailUiState.Success, 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 cd068cbf..9f60ab74 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 @@ -11,12 +11,11 @@ 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 de.jeanlucmakiola.calendula.domain.toDuplicateForm +import de.jeanlucmakiola.calendula.ui.common.deleteEvent +import de.jeanlucmakiola.calendula.ui.common.stageEventShare import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext -import kotlin.time.Clock import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -113,14 +112,7 @@ class EventDetailViewModel @Inject constructor( viewModelScope.launch { _deleteState.value = DeleteUiState.Deleting _deleteState.value = try { - when (scope) { - RecurringWriteScope.AllEvents -> - repository.deleteEvent(target.eventId) - RecurringWriteScope.ThisEvent -> - repository.deleteOccurrence(target.eventId, target.beginMillis) - RecurringWriteScope.ThisAndFollowing -> - repository.deleteEventFromOccurrence(target.eventId, target.beginMillis) - } + repository.deleteEvent(target.eventId, target.beginMillis, scope) DeleteUiState.Deleted } catch (e: CancellationException) { throw e @@ -144,40 +136,16 @@ class EventDetailViewModel @Inject constructor( */ suspend fun shareUri(): Uri? { val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null - return runCatching { - withContext(io) { - val ics = IcsWriter().writeCalendar( - events = listOf(detail.toShareIcsEvent()), - dtStamp = Clock.System.now(), - ) - icsExporter.stageShareFile(shareFileName(detail.instance.title), ics) - } - }.getOrNull() + return withContext(io) { icsExporter.stageEventShare(detail) } } /** - * 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. + * A create form seeded from the open event, to save a copy as a new event + * (#52) — see [toDuplicateForm]. Null when nothing is loaded. */ 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 }, - ) + return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault()) } 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 } } - -/** 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" -}