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.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.CalendarsScreen
|
||||
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.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
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.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.rememberCalendarSlideSpec
|
||||
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
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -341,6 +348,9 @@ 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,
|
||||
@@ -355,19 +365,31 @@ fun CalendarHost(
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// 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 reduceMotion = rememberReduceMotion()
|
||||
val morphBounds = rememberViewMorphBoundsTransform()
|
||||
val morphFade = rememberCalendarFadeSpec()
|
||||
CompositionLocalProvider(
|
||||
LocalEventMove provides moveScope,
|
||||
// The failure state's way out when every calendar is switched off
|
||||
// (#239); nothing else in the calendar surfaces navigates here.
|
||||
LocalManageCalendars provides { showCalendars = true },
|
||||
) {
|
||||
SharedTransitionLayout(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
CompositionLocalProvider(
|
||||
LocalViewMorph provides if (reduceMotion) {
|
||||
null
|
||||
} else {
|
||||
ViewMorphScope(this@SharedTransitionLayout, this@AnimatedContent, morphBounds, morphFade)
|
||||
},
|
||||
) {
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
@@ -430,6 +452,8 @@ fun CalendarHost(
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +466,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
@@ -455,7 +479,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
@@ -483,7 +507,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
@@ -500,7 +524,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
@@ -520,7 +544,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
@@ -577,7 +601,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
@@ -589,7 +613,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -420,7 +422,7 @@ private fun AgendaList(
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
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.mutableStateMapOf
|
||||
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.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
@@ -403,16 +407,18 @@ private fun DayPager(
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
DayPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||
DayPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,6 +628,7 @@ private fun AllDayStrip(
|
||||
onClick = { onEventClick(span.event) },
|
||||
modifier = Modifier
|
||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||
.morphEvent(ViewMorphKey.event(span.event, state.date))
|
||||
.width(barWidth)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
@@ -844,6 +851,7 @@ private fun DayColumnCard(
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.morphEvent(ViewMorphKey.event(block.event, date))
|
||||
.width(place.width)
|
||||
.height(blockHeight)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
|
||||
@@ -10,6 +10,11 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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.pagedHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
@@ -669,7 +674,7 @@ private fun MonthPager(
|
||||
) {
|
||||
if (static) {
|
||||
Box(modifier) {
|
||||
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
|
||||
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -688,7 +693,15 @@ private fun MonthPager(
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { 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(
|
||||
index: Int,
|
||||
active: Boolean,
|
||||
morphActive: Boolean,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
loading: @Composable () -> Unit,
|
||||
@@ -705,9 +719,11 @@ private fun MonthPage(
|
||||
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
||||
val chipDrag = LocalMonthDrag.current
|
||||
val morph = LocalMonthMorph.current
|
||||
val viewMorph = LocalViewMorph.current
|
||||
CompositionLocalProvider(
|
||||
LocalMonthDrag provides chipDrag.takeIf { active },
|
||||
LocalMonthMorph provides morph.takeIf { active },
|
||||
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
||||
) {
|
||||
when (val s = state) {
|
||||
is MonthUiState.Success -> page(index, s)
|
||||
@@ -1334,6 +1350,10 @@ private fun SplitMonthContent(
|
||||
// its month still for the length of the cross-fade.
|
||||
val leaving = transition.targetState == EnterExitState.PostExit
|
||||
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
|
||||
// travels, and the cross-fade above is the whole transition.
|
||||
LocalMonthMorph provides if (reduceMotion) {
|
||||
@@ -2254,6 +2274,17 @@ private fun MonthWeekRow(
|
||||
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)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
@@ -2311,6 +2342,13 @@ private fun MonthWeekRow(
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.then(
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.morphEvent(ViewMorphKey.event(ev, d))
|
||||
},
|
||||
)
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
@@ -2576,6 +2614,7 @@ private fun DayNumberCell(
|
||||
if (isToday) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.morphDay(date)
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -2596,6 +2635,7 @@ private fun DayNumberCell(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible,
|
||||
softWrap = false,
|
||||
modifier = Modifier.morphDay(date),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
@@ -2603,6 +2643,7 @@ private fun DayNumberCell(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
||||
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.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
@@ -90,6 +89,8 @@ fun SettingsScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -106,28 +107,28 @@ fun SettingsScreen(
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Appearance,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Views,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.EventForm,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Notifications,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
NotificationsScreen(
|
||||
state = state,
|
||||
@@ -139,21 +140,21 @@ fun SettingsScreen(
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Widgets,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
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.ViewSwitcherPill
|
||||
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.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -525,17 +530,19 @@ private fun WeekPager(
|
||||
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||
onDispose { stripHeights.remove(page) }
|
||||
}
|
||||
WeekPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||
WeekPage(
|
||||
state = loaded ?: empty,
|
||||
allDayHeight = allDayHeight,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -776,7 +783,7 @@ private fun WeekDayHeader(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.morphDay(date).fillMaxSize(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
@@ -790,6 +797,7 @@ private fun WeekDayHeader(
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.morphDay(date),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -886,6 +894,7 @@ private fun AllDayStrip(
|
||||
x = colWidth * span.startCol,
|
||||
y = ALL_DAY_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol]))
|
||||
.width(colWidth * spanCols)
|
||||
.height(ALL_DAY_ROW_HEIGHT)
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
||||
@@ -1120,6 +1129,7 @@ private fun DayColumnCard(
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y + topInset)
|
||||
.morphEvent(ViewMorphKey.event(block.event, date))
|
||||
.width(place.width)
|
||||
.height(blockHeight)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user