From f7805cb0b50d0cd0fdaf5acc4c911f382e7bbbbb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:21:32 +0200 Subject: [PATCH] Morph events between views instead of fading (#184) --- .../calendula/ui/CalendarHost.kt | 44 +++++-- .../calendula/ui/agenda/AgendaScreen.kt | 4 +- .../calendula/ui/common/ViewMorph.kt | 115 ++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 28 +++-- .../calendula/ui/month/MonthScreen.kt | 45 ++++++- .../calendula/ui/settings/SettingsScreen.kt | 19 +-- .../calendula/ui/week/WeekScreen.kt | 34 ++++-- .../calendula/ui/common/ViewMorphKeyTest.kt | 50 ++++++++ docs/ARCHITECTURE.md | 5 + floret-kit | 2 +- 10 files changed, 301 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt 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 5f445ac7..887e615c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index bb4ef71b..ca64ec54 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -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) }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt new file mode 100644 index 00000000..503cc32f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -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, +) + +val LocalViewMorph = compositionLocalOf { 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() + return remember(spec) { BoundsTransform { _, _ -> spec } } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 8adc2f69..6c817dc1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 348ada32..cfc7fc89 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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, 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), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index eddae090..541a3676 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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(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 }) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 855d5fcb..b9e9b80d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt new file mode 100644 index 00000000..b0c5a298 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt @@ -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() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed3afdee..6431da65 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 driven by `AnimatedVisibility` with a *held-key* pattern: the last shown 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` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. diff --git a/floret-kit b/floret-kit index 14c13bae..1ab46897 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 14c13bae6d846845791e8b32eca6d016d2524364 +Subproject commit 1ab46897c30c663b2d590f26104ce8254a0be1df