Share the detail screen's duplicate, share and delete actions (#141)
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user