From 49cb11fe09a9300a1b7be0769072c4bdeefdff12 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 14:07:13 +0200 Subject: [PATCH] Share the host overlays' slide, held key and create request --- .../calendula/ui/CalendarHost.kt | 227 ++++++++---------- 1 file changed, 99 insertions(+), 128 deletions(-) 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 01234ce7..7894543c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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(null) } - var heldKey by remember { mutableStateOf(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(null) } - var heldCreateIso by remember { mutableStateOf(null) } - var createStartMinutes by rememberSaveable { mutableStateOf(null) } - var heldCreateMinutes by remember { mutableStateOf(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(null) } - var heldCreateDraftMillis by remember { mutableStateOf(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(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(null) } - var heldEditKey by remember { mutableStateOf(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, 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 rememberHeld(live: T?): T? { + val held = remember { HeldValue() } + if (live != null) held.value = live + return live ?: held.value +} + +private class HeldValue { + 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>( + 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?) }, +)