Morph events between views instead of fading (#184)
This commit is contained in:
@@ -3,10 +3,10 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -27,12 +27,18 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
@@ -72,6 +78,7 @@ import kotlin.time.Clock
|
|||||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||||
* widget's view.)
|
* widget's view.)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -341,6 +348,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
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
|
// 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,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
@@ -355,19 +365,31 @@ fun CalendarHost(
|
|||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. What both views show morphs across
|
||||||
|
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val morphBounds = rememberViewMorphBoundsTransform()
|
||||||
|
val morphFade = rememberCalendarFadeSpec()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalEventMove provides moveScope,
|
LocalEventMove provides moveScope,
|
||||||
// The failure state's way out when every calendar is switched off
|
// The failure state's way out when every calendar is switched off
|
||||||
// (#239); nothing else in the calendar surfaces navigates here.
|
// (#239); nothing else in the calendar surfaces navigates here.
|
||||||
LocalManageCalendars provides { showCalendars = true },
|
LocalManageCalendars provides { showCalendars = true },
|
||||||
) {
|
) {
|
||||||
|
SharedTransitionLayout(Modifier.fillMaxSize()) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
label = "view-switch",
|
label = "view-switch",
|
||||||
) { currentView ->
|
) { currentView ->
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalViewMorph provides if (reduceMotion) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
ViewMorphScope(this@SharedTransitionLayout, this@AnimatedContent, morphBounds, morphFade)
|
||||||
|
},
|
||||||
|
) {
|
||||||
when (currentView) {
|
when (currentView) {
|
||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -430,6 +452,8 @@ fun CalendarHost(
|
|||||||
todayInToolbar = todayInToolbar,
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +466,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSearch,
|
visible = showSearch,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onBack = { showSearch = false },
|
onBack = { showSearch = false },
|
||||||
@@ -455,7 +479,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = detailKey != null,
|
visible = detailKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
activeKey?.let { key ->
|
activeKey?.let { key ->
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
@@ -483,7 +507,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = createDateIso != null,
|
visible = createDateIso != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -500,7 +524,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = editKey != null,
|
visible = editKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(editKey ?: heldEditKey)?.let { key ->
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -520,7 +544,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
@@ -577,7 +601,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showCalendars,
|
visible = showCalendars,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(
|
CalendarsScreen(
|
||||||
onBack = { showCalendars = false },
|
onBack = { showCalendars = false },
|
||||||
@@ -589,7 +613,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackup,
|
visible = showBackup,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
// Settings can open this screen without the manager underneath, so
|
// Settings can open this screen without the manager underneath, so
|
||||||
// its failure states' "Manage calendars" way out has to open the
|
// its failure states' "Manage calendars" way out has to open the
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -420,7 +422,7 @@ private fun AgendaList(
|
|||||||
zone = zone,
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.BoundsTransform
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the views share when switching between them (#184): an event visible in
|
||||||
|
* both grows from where it sat into where it lands, a date label travels to its
|
||||||
|
* new seat, and everything else fades through.
|
||||||
|
*
|
||||||
|
* An element is tagged with the day it is drawn *from*, not just its instance: a
|
||||||
|
* multi-day event is drawn once per day, per week row or per timeline column,
|
||||||
|
* and only one element per key may take part. The segment that starts on the
|
||||||
|
* same day in both views is the one that travels; the rest fade where they are.
|
||||||
|
*/
|
||||||
|
sealed interface ViewMorphKey {
|
||||||
|
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
data class Day(val date: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** [event]'s occurrence as drawn from [day]. */
|
||||||
|
fun event(event: EventInstance, day: LocalDate): Event =
|
||||||
|
Event(event.eventId, event.start.toEpochMilliseconds(), day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
class ViewMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
val boundsTransform: BoundsTransform,
|
||||||
|
val fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pager page may carry the morph tags: only the settled one. Its
|
||||||
|
* neighbours are composed ahead and repeat some of its dates, which would both
|
||||||
|
* claim its keys and fly in from off screen.
|
||||||
|
*/
|
||||||
|
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
|
||||||
|
|
||||||
|
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
|
||||||
|
@Composable
|
||||||
|
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val morph = LocalViewMorph.current
|
||||||
|
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
|
||||||
|
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
|
||||||
|
* real size and is clipped while there is no room, instead of smearing.
|
||||||
|
*
|
||||||
|
* Goes after the element's offset and before its size, so the travelling bounds
|
||||||
|
* drive the measurement.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphEvent.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
zIndexInOverlay = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags a day's number, which is small on both sides, so it scales on its way. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphDay.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberViewMorphBoundsTransform(): BoundsTransform {
|
||||||
|
val spec = MaterialTheme.motionScheme.defaultSpatialSpec<Rect>()
|
||||||
|
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -403,16 +407,18 @@ private fun DayPager(
|
|||||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||||
onDispose { stripHeights.remove(page) }
|
onDispose { stripHeights.remove(page) }
|
||||||
}
|
}
|
||||||
DayPage(
|
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||||
state = loaded ?: empty,
|
DayPage(
|
||||||
allDayHeight = allDayHeight,
|
state = loaded ?: empty,
|
||||||
scrollState = scrollState,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
scrollState = scrollState,
|
||||||
active = page == pagerState.currentPage,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
active = page == pagerState.currentPage,
|
||||||
onCreateAt = onCreateAt,
|
onEventClick = onEventClick,
|
||||||
onDrop = onDrop,
|
onCreateAt = onCreateAt,
|
||||||
)
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,6 +628,7 @@ private fun AllDayStrip(
|
|||||||
onClick = { onEventClick(span.event) },
|
onClick = { onEventClick(span.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||||
|
.morphEvent(ViewMorphKey.event(span.event, state.date))
|
||||||
.width(barWidth)
|
.width(barWidth)
|
||||||
.height(ALL_DAY_ROW_HEIGHT)
|
.height(ALL_DAY_ROW_HEIGHT)
|
||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
@@ -844,6 +851,7 @@ private fun DayColumnCard(
|
|||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = place.x, y = place.y + topInset)
|
.offset(x = place.x, y = place.y + topInset)
|
||||||
|
.morphEvent(ViewMorphKey.event(block.event, date))
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(blockHeight)
|
.height(blockHeight)
|
||||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import androidx.compose.foundation.pager.PagerState
|
|||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
|
||||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
@@ -669,7 +674,7 @@ private fun MonthPager(
|
|||||||
) {
|
) {
|
||||||
if (static) {
|
if (static) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
|
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -688,7 +693,15 @@ private fun MonthPager(
|
|||||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||||
),
|
),
|
||||||
) { index ->
|
) { index ->
|
||||||
MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page)
|
MonthPage(
|
||||||
|
index = index,
|
||||||
|
active = index == pagerState.currentPage,
|
||||||
|
morphActive = morphPageActive(index, pagerState.settledPage),
|
||||||
|
weekStart = weekStart,
|
||||||
|
monthState = monthState,
|
||||||
|
loading = loading,
|
||||||
|
page = page,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +709,7 @@ private fun MonthPager(
|
|||||||
private fun MonthPage(
|
private fun MonthPage(
|
||||||
index: Int,
|
index: Int,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
|
morphActive: Boolean,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||||
loading: @Composable () -> Unit,
|
loading: @Composable () -> Unit,
|
||||||
@@ -705,9 +719,11 @@ private fun MonthPage(
|
|||||||
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
||||||
val chipDrag = LocalMonthDrag.current
|
val chipDrag = LocalMonthDrag.current
|
||||||
val morph = LocalMonthMorph.current
|
val morph = LocalMonthMorph.current
|
||||||
|
val viewMorph = LocalViewMorph.current
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalMonthDrag provides chipDrag.takeIf { active },
|
LocalMonthDrag provides chipDrag.takeIf { active },
|
||||||
LocalMonthMorph provides morph.takeIf { active },
|
LocalMonthMorph provides morph.takeIf { active },
|
||||||
|
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is MonthUiState.Success -> page(index, s)
|
is MonthUiState.Success -> page(index, s)
|
||||||
@@ -1334,6 +1350,10 @@ private fun SplitMonthContent(
|
|||||||
// its month still for the length of the cross-fade.
|
// its month still for the length of the cross-fade.
|
||||||
val leaving = transition.targetState == EnterExitState.PostExit
|
val leaving = transition.targetState == EnterExitState.PostExit
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
// The split style morphs within itself and so sits the view
|
||||||
|
// switch's morph out (#184): one element can't travel in two
|
||||||
|
// shared transitions at once.
|
||||||
|
LocalViewMorph provides null,
|
||||||
// Null under reduced motion: nothing is tagged, nothing
|
// Null under reduced motion: nothing is tagged, nothing
|
||||||
// travels, and the cross-fade above is the whole transition.
|
// travels, and the cross-fade above is the whole transition.
|
||||||
LocalMonthMorph provides if (reduceMotion) {
|
LocalMonthMorph provides if (reduceMotion) {
|
||||||
@@ -2254,6 +2274,17 @@ private fun MonthWeekRow(
|
|||||||
instanceId = span.event.instanceId,
|
instanceId = span.event.instanceId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A blanked day is drawn again in its own
|
||||||
|
// month's block, which owns the tag.
|
||||||
|
.then(
|
||||||
|
week.days[span.startCol].let { start ->
|
||||||
|
if (blankOutside && !inMonth(start)) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.morphEvent(ViewMorphKey.event(span.event, start))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
.width(colW * cols)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
@@ -2311,6 +2342,13 @@ private fun MonthWeekRow(
|
|||||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||||
)
|
)
|
||||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||||
|
.then(
|
||||||
|
if (blankOutside && !inMonth(d)) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.morphEvent(ViewMorphKey.event(ev, d))
|
||||||
|
},
|
||||||
|
)
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
@@ -2576,6 +2614,7 @@ private fun DayNumberCell(
|
|||||||
if (isToday) {
|
if (isToday) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.morphDay(date)
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -2596,6 +2635,7 @@ private fun DayNumberCell(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Visible,
|
overflow = TextOverflow.Visible,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
|
modifier = Modifier.morphDay(date),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
@@ -2603,6 +2643,7 @@ private fun DayNumberCell(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.morphDay(date),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -90,6 +89,8 @@ fun SettingsScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -106,28 +107,28 @@ fun SettingsScreen(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Appearance,
|
visible = section == SettingsSection.Appearance,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Views,
|
visible = section == SettingsSection.Views,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.EventForm,
|
visible = section == SettingsSection.EventForm,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Notifications,
|
visible = section == SettingsSection.Notifications,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
NotificationsScreen(
|
NotificationsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -139,21 +140,21 @@ fun SettingsScreen(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.SpecialDates,
|
visible = section == SettingsSection.SpecialDates,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Widgets,
|
visible = section == SettingsSection.Widgets,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Licences,
|
visible = section == SettingsSection.Licences,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
LicencesScreen(onBack = { section = null })
|
LicencesScreen(onBack = { section = null })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
|
||||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -525,17 +530,19 @@ private fun WeekPager(
|
|||||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||||
onDispose { stripHeights.remove(page) }
|
onDispose { stripHeights.remove(page) }
|
||||||
}
|
}
|
||||||
WeekPage(
|
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||||
state = loaded ?: empty,
|
WeekPage(
|
||||||
allDayHeight = allDayHeight,
|
state = loaded ?: empty,
|
||||||
scrollState = scrollState,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
scrollState = scrollState,
|
||||||
active = page == pagerState.currentPage,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
active = page == pagerState.currentPage,
|
||||||
onOpenDay = onOpenDay,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onOpenDay = onOpenDay,
|
||||||
onDrop = onDrop,
|
onCreateAt = onCreateAt,
|
||||||
)
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -776,7 +783,7 @@ private fun WeekDayHeader(
|
|||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.morphDay(date).fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
@@ -790,6 +797,7 @@ private fun WeekDayHeader(
|
|||||||
Text(
|
Text(
|
||||||
text = date.day.toString(),
|
text = date.day.toString(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.morphDay(date),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -886,6 +894,7 @@ private fun AllDayStrip(
|
|||||||
x = colWidth * span.startCol,
|
x = colWidth * span.startCol,
|
||||||
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||||
)
|
)
|
||||||
|
.morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol]))
|
||||||
.width(colWidth * spanCols)
|
.width(colWidth * spanCols)
|
||||||
.height(ALL_DAY_ROW_HEIGHT)
|
.height(ALL_DAY_ROW_HEIGHT)
|
||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||||
@@ -1120,6 +1129,7 @@ private fun DayColumnCard(
|
|||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = place.x, y = place.y + topInset)
|
.offset(x = place.x, y = place.y + topInset)
|
||||||
|
.morphEvent(ViewMorphKey.event(block.event, date))
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(blockHeight)
|
.height(blockHeight)
|
||||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ViewMorphKeyTest {
|
||||||
|
|
||||||
|
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
|
||||||
|
instanceId = instanceId,
|
||||||
|
eventId = eventId,
|
||||||
|
calendarId = 1,
|
||||||
|
title = "Standup",
|
||||||
|
start = Instant.fromEpochMilliseconds(startMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val day = LocalDate(2026, 9, 25)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same occurrence on the same day matches across views`() {
|
||||||
|
// Each view reads its own instances query, so the row id is no anchor.
|
||||||
|
assertThat(ViewMorphKey.event(instance(instanceId = 1), day))
|
||||||
|
.isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day event keys each day it is drawn from apart`() {
|
||||||
|
assertThat(ViewMorphKey.event(instance(1), day))
|
||||||
|
.isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two occurrences of one series stay apart`() {
|
||||||
|
assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day))
|
||||||
|
.isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the settled page morphs`() {
|
||||||
|
assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue()
|
||||||
|
assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse()
|
||||||
|
assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,11 @@ gates on the first-launch wizard (`ui/onboarding/`), then shows
|
|||||||
plus overlay state for detail, edit, and settings — full-screen overlays
|
plus overlay state for detail, edit, and settings — full-screen overlays
|
||||||
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
||||||
key stays alive through the slide-out so content never flashes empty.
|
key stays alive through the slide-out so content never flashes empty.
|
||||||
|
The views themselves switch inside a `SharedTransitionLayout`: an event shown
|
||||||
|
in both morphs from its old bounds to its new ones, keyed per occurrence *and*
|
||||||
|
per day it is drawn from (`ViewMorphKey`), and only a pager's settled page
|
||||||
|
carries the tags. Overlays leave through floret-kit's `predictiveBackExit`, so
|
||||||
|
a committed back gesture finishes from its scaled preview.
|
||||||
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
||||||
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
`onNewIntent`) as an external detail key that `CalendarHost` consumes
|
||||||
exactly like an event tap.
|
exactly like an event tap.
|
||||||
|
|||||||
+1
-1
Submodule floret-kit updated: 14c13bae6d...1ab46897c3
Reference in New Issue
Block a user