Share the host overlays' slide, held key and create request
This commit is contained in:
@@ -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?) },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user