Share the host overlays' slide, held key and create request

This commit is contained in:
2026-10-16 14:07:39 +02:00
parent 32668623a6
commit 49cb11fe09
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState
@@ -28,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
@@ -39,6 +41,7 @@ import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
@@ -99,7 +102,6 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Instant
/**
@@ -152,7 +154,7 @@ fun CalendarHost(
val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
@@ -183,26 +185,15 @@ fun CalendarHost(
// it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
// placeholder — so the destination never loads a bogus id=0 on first frame.
// Like every overlay below, it draws its last key through the slide-out
// ([rememberHeld]) rather than a placeholder.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onOpenDetail: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
// A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey
requests.detailKey = null
}
@@ -228,31 +219,17 @@ fun CalendarHost(
// calendar list rather than by preferences. Reached from both.
var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
// when the form is opened from a day/week grid tap; null from the FAB.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
// A drafted block's start and end (#153) as epoch millis. Instants rather than
// minutes into a day, so the form opens at the times drawn even across a DST
// change (#387).
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
heldCreateIso = date.toString()
createDateIso = date.toString()
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
heldCreateDraftMillis = draftMillis
createDraftMillis = draftMillis
// Event form (v1.2 create), opened on a date, a tapped slot or a drafted block.
var createRequest by rememberSaveable(stateSaver = createRequestSaver) {
mutableStateOf<CreateRequest?>(null)
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
fun openCreate(dateIso: String, startMinutes: Int? = null, draftMillis: LongArray? = null) {
createRequest = CreateRequest(dateIso, startMinutes, draftMillis)
}
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date.toString(), startMinutes) }
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
}
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -262,7 +239,6 @@ fun CalendarHost(
// falling back to the auto-refreshing calendar is the only honest
// destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk
@@ -359,13 +335,12 @@ fun CalendarHost(
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
// without this it would open hidden underneath them. A saved edit just
// returns to the calendar.
val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
requests.editKey = null
}
@@ -386,7 +361,7 @@ fun CalendarHost(
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
createRequest = null
focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
@@ -397,11 +372,9 @@ fun CalendarHost(
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
createRequest = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
requests.nav = null
}
is WidgetNavRequest.OpenView -> {
@@ -410,8 +383,8 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
focus.navigateTo(localToday())
createRequest = null
focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
requests.nav = null
}
@@ -421,14 +394,7 @@ fun CalendarHost(
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
openCreate(req.dateIso ?: currentDate().toString())
requests.nav = null
}
null -> {}
@@ -442,15 +408,7 @@ fun CalendarHost(
val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldEditKey = key
editKey = key
}
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
// A tap opens the edit form instead of the detail screen when the user chose
// so (#178) — only for events whose calendar takes writes. Both are read at
// tap time, so a tap before they've loaded still routes right.
@@ -488,15 +446,10 @@ fun CalendarHost(
)
}
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null
// The view switch runs on a seekable transition, so a back gesture can scrub
@@ -690,11 +643,7 @@ fun CalendarHost(
// Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results.
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showSearch) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
@@ -702,23 +651,15 @@ fun CalendarHost(
)
}
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
activeKey?.let { key ->
val shownDetailKey = rememberHeld(detailKey)
SlideOverlay(visible = detailKey != null) {
shownDetailKey?.let { key ->
EventDetailScreen(
eventId = key[0],
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
onEdit = {
heldEditKey = key
editKey = key
},
onEdit = { editKey = key },
onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an
@@ -732,32 +673,26 @@ fun CalendarHost(
}
// Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
val shownCreate = rememberHeld(createRequest)
SlideOverlay(visible = createRequest != null) {
shownCreate?.let { create ->
EventEditScreen(
initialDateIso = iso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
initialDateIso = create.dateIso,
initialStartMinutes = create.startMinutes,
initialDraft = create.draftMillis,
onClose = { createRequest = null },
onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
visible = createRequest != null,
viewModel = hiltViewModel(key = "create"),
)
}
}
// Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
val shownEditKey = rememberHeld(editKey)
SlideOverlay(visible = editKey != null) {
shownEditKey?.let { key ->
EventEditScreen(
initialDateIso = null,
editKey = key,
@@ -774,10 +709,7 @@ fun CalendarHost(
// below to show why, so open it.
onLoadFailed = {
editKey = null
if (detailKey == null) {
heldKey = key
detailKey = key
}
if (detailKey == null) detailKey = key
},
visible = editKey != null,
viewModel = hiltViewModel(key = "edit"),
@@ -786,11 +718,7 @@ fun CalendarHost(
}
// Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showSettings) {
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
@@ -848,11 +776,7 @@ fun CalendarHost(
// Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76).
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showCalendars) {
CalendarsScreen(
onBack = { showCalendars = false },
onOpenBackup = { showBackup = true },
@@ -861,11 +785,7 @@ fun CalendarHost(
}
// Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SlideOverlay(visible = showBackup) {
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings
@@ -904,5 +824,56 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* An overlay's slide-in from the end edge, leaving from the predictive-back
* preview (#184): shrinking on from where the gesture let go instead of
* snapping back and sliding off.
*/
@Composable
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
exit = predictiveBackExit(),
content = content,
)
}
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
private fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
/**
* A create-form open: its date, plus the tapped slot's start (minutes from
* midnight) from a day/week grid tap, or a drafted block's start and end (#153)
* as epoch millis. Instants rather than minutes into a day, so the form opens at
* the times drawn even across a DST change (#387).
*/
private class CreateRequest(
val dateIso: String,
val startMinutes: Int?,
val draftMillis: LongArray?,
)
private val createRequestSaver = Saver<CreateRequest?, List<Any?>>(
save = { request -> request?.let { listOf(it.dateIso, it.startMinutes, it.draftMillis) } },
restore = { CreateRequest(it[0] as String, it[1] as Int?, it[2] as LongArray?) },
)