From 62ea5ce9d3d46ace01c71cc3973a82a642f2fc0f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 21:12:22 +0200 Subject: [PATCH] Share one top bar across views and smooth the view switches (#184) --- .../calendula/ui/CalendarHost.kt | 135 +++++-- .../calendula/ui/agenda/AgendaScreen.kt | 261 ++++--------- .../calendula/ui/common/CalendarChrome.kt | 161 ++++++++ .../calendula/ui/common/CalendarFabColumn.kt | 3 +- .../ui/common/CalendarTitleButton.kt | 33 +- .../ui/common/CalendarTransitions.kt | 17 + .../calendula/ui/common/TimelineScroll.kt | 26 +- .../calendula/ui/common/TodayAction.kt | 3 +- .../calendula/ui/common/ViewMorph.kt | 153 ++------ .../calendula/ui/common/ViewSwitcherPill.kt | 5 +- .../calendula/ui/month/MonthScreen.kt | 309 +++++---------- .../calendula/ui/week/TimelineMorph.kt | 5 + .../calendula/ui/week/WeekScreen.kt | 358 ++++++------------ .../calendula/ui/week/WeekViewModel.kt | 17 + docs/ARCHITECTURE.md | 7 +- 15 files changed, 677 insertions(+), 816 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.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 e3e38c4c..c5b2f59b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -9,16 +9,22 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import de.jeanlucmakiola.calendula.ui.common.screen import de.jeanlucmakiola.calendula.ui.common.ViewScreen @@ -34,6 +40,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.calendula.ui.common.BelowTopBarClip +import de.jeanlucmakiola.calendula.ui.common.StayingPut +import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFab +import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar +import de.jeanlucmakiola.calendula.ui.common.ChromeHost +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot +import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.week.DayViewModel +import de.jeanlucmakiola.calendula.ui.week.MultiDayViewModel +import de.jeanlucmakiola.calendula.ui.week.TimelineViewModels +import de.jeanlucmakiola.calendula.ui.week.WeekViewModel import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -64,6 +82,8 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -130,14 +150,33 @@ fun CalendarHost( } val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline SideEffect { heldTimeline = timelineView } - val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } // The date every view opens on and carries along (#184); tapping a day // anywhere focuses it and opens the day view there. val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } + + // Into the timeline from another screen, its page is loaded first, briefly, + // so its events are there to morph into rather than arriving mid-way. + val timelineModels = TimelineViewModels( + hiltViewModel(), + hiltViewModel(), + hiltViewModel(), + ) + val switchScope = rememberCoroutineScope() + val switchTo: (CalendarView, (List) -> List) -> Unit = { target, move -> + if (target.screen == ViewScreen.Timeline && viewStack.last().screen != ViewScreen.Timeline) { + switchScope.launch { + withTimeoutOrNull(VIEW_DATA_WAIT_MS) { timelineModels[target].prepare(focus.date) } + viewStack = move(viewStack) + } + } else { + viewStack = move(viewStack) + } + } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo(target) { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> focus.date = date - viewStack = viewStack.drillToDay() + switchTo(CalendarView.Day) { it.drillToDay() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -392,6 +431,9 @@ fun CalendarHost( val morphBounds = rememberViewMorphBoundsTransform() val morphFade = rememberCalendarFadeSpec() val morphClip = remember { BelowTopBarClip() } + val stayingPut = remember { StayingPut() } + val chromeHost = remember { ChromeHost() } + val drawerState = rememberDrawerState(DrawerValue.Closed) val density = LocalDensity.current val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } @@ -404,10 +446,60 @@ fun CalendarHost( // (#239); nothing else in the calendar surfaces navigates here. LocalManageCalendars provides { showCalendars = true }, ) { + // One drawer, top bar and FAB for every screen (#184): a switch changes + // what they say, from what the screen on show publishes, rather than + // swapping them for another screen's. + ModalNavigationDrawer( + drawerState = drawerState, + // Open only via the menu button where edge-swipe would fight paging; + // the agenda has no paging to fight. + gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda, + drawerContent = { + CalendarDrawer( + currentView = view, + currentDate = chromeHost.chrome?.drawerDate ?: focus.date, + drawerState = drawerState, + viewOrder = drawerViewOrder, + onSelectView = { target -> + // Once the drawer is out of the way, so the switch is seen whole. + switchScope.launch { + try { + drawerState.close() + } finally { + onSelectView(target) + } + } + }, + onJumpToDate = { target -> + chromeHost.actions?.onJumpToDate?.invoke(target) + switchScope.launch { drawerState.close() } + }, + onSettings = { + onOpenSettings() + switchScope.launch { drawerState.close() } + }, + ) + }, + ) { + Scaffold( + topBar = { + CalendarTopBar( + host = chromeHost, + selectedView = view, + quickSwitchViews = quickSwitchViews, + onCycleView = { onSelectView(view.next(quickSwitchViews)) }, + onOpenDrawer = { switchScope.launch { drawerState.open() } }, + onOpenSearch = onOpenSearch, + showTodayButton = todayInToolbar, + ) + }, + floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) }, + ) { innerPadding -> SharedTransitionLayout( Modifier + .padding(innerPadding) .fillMaxSize() - .onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, +.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y }, ) { // Keyed on the screen, not the view: the day, multi-day and week views // are one timeline that resizes between them itself (#184), so switching @@ -419,52 +511,38 @@ fun CalendarHost( ) { screen -> val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent - val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip) { - if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip) + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) } - CompositionLocalProvider(LocalViewMorph provides viewMorph) { + CompositionLocalProvider( + LocalViewMorph provides viewMorph, + LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen), + ) { when (screen) { ViewScreen.Timeline -> TimelineScreen( view = timelineView, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) ViewScreen.Month -> MonthScreen( - selectedView = CalendarView.Month, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) ViewScreen.Agenda -> AgendaScreen( - selectedView = CalendarView.Agenda, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) } } } } } + } + } // Scope prompt + confirmation/undo snackbar for a dropped event, declared // right after the calendar views so later overlays cover it. @@ -670,3 +748,6 @@ private val viewStackSaver = listSaver, Int>( private fun localToday(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + +/** How long a switch into the timeline waits for its page before going anyway. */ +private const val VIEW_DATA_WAIT_MS = 200L 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 f42499c0..209867fb 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,8 +1,14 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import kotlinx.coroutines.flow.drop +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey @@ -25,37 +31,22 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Coffee -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -68,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale @@ -91,16 +77,10 @@ import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgendaScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { @@ -117,10 +97,6 @@ fun AgendaScreen( val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -152,111 +128,78 @@ fun AgendaScreen( (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = anchor, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - focus?.date = target - viewModel.goToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - AgendaTopBar( - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } + PublishChrome( + ScreenChrome( + title = stringResource(R.string.view_agenda), + titleDate = null, + drawerDate = anchor, + awayFromToday = !isOnToday || (scrolledAway && successState != null), + todayText = stringResource(R.string.agenda_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = { target -> + focus?.date = target + viewModel.goToDate(target) }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = (!isOnToday || (scrolledAway && successState != null)) && - !todayInToolbar, - todayText = stringResource(R.string.agenda_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(anchor, null) }, - ) + onCreate = { onCreateEvent(anchor, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> + // end lines the selector up with whatever ends the top bar: + // the view switcher's background, or — once #150 hides it — + // the search icon's glyph. + val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { + AppBarSpacing.Inset + } else { + AppBarSpacing.IconTrailingInset } - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // One bar at the top: the "showing …" header on the left and the - // session range switcher on the right (one settings toggle). - successState?.takeIf { it.showRangeBar }?.let { s -> - // end lines the selector up with whatever ends the top bar: - // the view switcher's background, or — once #150 hides it — - // the search icon's glyph. - val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { - AppBarSpacing.Inset - } else { - AppBarSpacing.IconTrailingInset - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = RANGE_BAR_TEXT_INSET, - end = selectorEnd, - top = 8.dp, - bottom = 8.dp, - ), - ) { - AgendaRangeBanner( - range = s.range, - start = s.anchor, - end = s.rangeEnd, - modifier = Modifier.weight(1f), - ) - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - ) - } - } - AgendaContent( - state = state, - pastDisplay = pastDisplay, - showToday = showToday, - listState = listState, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onTopDay = { focus?.date = it }, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) + .fillMaxWidth() + .padding( + start = RANGE_BAR_TEXT_INSET, + end = selectorEnd, + top = 8.dp, + bottom = 8.dp, + ), + ) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } + AgendaContent( + state = state, + pastDisplay = pastDisplay, + showToday = showToday, + listState = listState, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onTopDay = { focus?.date = it }, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } @@ -500,59 +443,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AgendaTopBar( - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - // A plain label rather than a CalendarTitleButton, so it takes that - // one's inset and one-line clamp itself. - Text( - text = stringResource(R.string.view_agenda), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .morphChrome(ChromeSlot.Title) - .padding(start = AppBarSpacing.TitleInset), - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt new file mode 100644 index 00000000..173d29d2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import kotlinx.datetime.LocalDate + +/** + * What a calendar screen shows in the top bar, the FAB and the drawer, which the + * host draws once for all of them (#184): switching screens changes what they + * say rather than swapping one bar for another. + * + * @param titleDate the date the title's picker opens on, or null for a title + * that is only a label. + * @param drawerDate the date the drawer's picker opens on. + * @param awayFromToday whether "today" is worth offering. + */ +@Immutable +data class ScreenChrome( + val title: String, + val shortTitle: String = title, + val titleDate: LocalDate?, + val drawerDate: LocalDate, + val awayFromToday: Boolean, + val todayText: String, +) + +/** What the chrome does for the screen on show; read when used, so not observed. */ +class ScreenActions( + val onToday: () -> Unit, + val onJumpToDate: (LocalDate) -> Unit, + val onCreate: () -> Unit, +) + +/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ +@Stable +class ChromeHost { + var chrome by mutableStateOf(null) + private set + + var actions: ScreenActions? = null + private set + + internal fun publish(chrome: ScreenChrome, actions: ScreenActions) { + this.chrome = chrome + this.actions = actions + } +} + +/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */ +class ChromeSlot(val host: ChromeHost, val current: Boolean) + +val LocalChromeSlot = staticCompositionLocalOf { null } + +/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */ +@Composable +fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { + val slot = LocalChromeSlot.current ?: return + if (slot.current) SideEffect { slot.host.publish(chrome, actions) } +} + +/** The top bar every calendar screen shares. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarTopBar( + host: ChromeHost, + selectedView: CalendarView, + quickSwitchViews: List, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, + showTodayButton: Boolean, +) { + val chrome = host.chrome + TopAppBar( + title = { + if (chrome != null) { + CalendarTitleButton( + title = chrome.title, + currentDate = chrome.titleDate, + onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) }, + shortTitle = chrome.shortTitle, + ) + } + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() }) + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } + ViewSwitcherPill( + current = selectedView, + cycle = quickSwitchViews, + onCycle = onCycleView, + ) + }, + // Deliberately flat: M3 lifts the bar to mark content scrolling under + // it, but here the bar meets the header on the same surface and the + // tint is what makes that seam look like a separate block (#186). + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +/** The FAB column every calendar screen shares. */ +@Composable +fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { + val chrome = host.chrome ?: return + CalendarFabColumn( + todayVisible = chrome.awayFromToday && !todayInToolbar, + todayText = chrome.todayText, + onToday = { host.actions?.onToday?.invoke() }, + onCreate = { host.actions?.onCreate?.invoke() }, + ) +} + +/** + * A calendar screen's body under the shared chrome, on the ground the screen's + * own Scaffold used to give it; the host's Scaffold has already made room for + * the bars, so [content] gets no padding of its own. + */ +@Composable +fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) { + Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + content(PaddingValues()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 44ee7c37..bc686171 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -15,7 +15,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -49,7 +48,7 @@ fun CalendarFabColumn( text = { Text(todayText) }, ) } - FloatingActionButton(onClick = onCreate, modifier = Modifier.morphChrome(ChromeSlot.Create)) { + FloatingActionButton(onClick = onCreate) { Icon( imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.event_edit_new_title), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index 73a4ae95..3f4f8902 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -1,6 +1,9 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -15,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -38,7 +41,8 @@ import kotlinx.datetime.LocalDate * drawer uses and reporting the chosen day through [onJumpToDate]. * * [currentDate] seeds the picker with whatever the bar is currently naming (the - * visible day, week start or month anchor). + * visible day, week start or month anchor); null makes the title a plain label, + * as the agenda's is, and the caret folds away. * * [shortTitle] replaces [title] when the full one does not fit the width the app * bar hands the title slot (#165). Either way the line clamps to one and @@ -47,7 +51,7 @@ import kotlinx.datetime.LocalDate @Composable fun CalendarTitleButton( title: String, - currentDate: LocalDate, + currentDate: LocalDate?, onJumpToDate: (LocalDate) -> Unit, modifier: Modifier = Modifier, shortTitle: String = title, @@ -56,10 +60,10 @@ fun CalendarTitleButton( Row( modifier = modifier - .morphChrome(ChromeSlot.Title) .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( + enabled = currentDate != null, onClickLabel = stringResource(R.string.drawer_jump_to_date), role = Role.Button, ) { showDatePicker = true } @@ -86,7 +90,6 @@ fun CalendarTitleButton( (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) }, contentAlignment = Alignment.CenterStart, - label = "calendar-title", ) { text -> Text( text = text, @@ -96,14 +99,20 @@ fun CalendarTitleButton( ) } } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AnimatedVisibility( + visible = currentDate != null, + enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(), + exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(), + ) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - if (showDatePicker) { + if (showDatePicker && currentDate != null) { CalendarDatePickerDialog( initial = currentDate, onConfirm = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 948d1f27..9c5c52ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec = @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + +/** + * A switch between views (#184): the default spatial spring, a little softer, as + * the whole screen reshapes rather than a page sliding by; softer still when + * [long], for a change the eye has further to follow. + */ +@Composable +fun rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec = + spring( + dampingRatio = VIEW_SWITCH_DAMPING, + stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS, + ) + +private const val VIEW_SWITCH_DAMPING = 0.9f +private const val VIEW_SWITCH_STIFFNESS = 450f +private const val LONG_VIEW_SWITCH_STIFFNESS = 340f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt index 3694757a..07af21cc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -2,9 +2,13 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf @@ -24,19 +28,34 @@ class TimelineScrollMemory { val LocalTimelineScroll = staticCompositionLocalOf { null } +/** A timeline's scroll, and where it opens before it can scroll there. */ +@Stable +class TimelineScroll(val state: ScrollState, noonPending: Boolean) { + /** Whether it is still to be put on noon, which it only can be once laid out. */ + var noonPending by mutableStateOf(noonPending) + internal set + + /** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */ + fun top(viewport: Float, content: Float): Float = + if (noonPending) ((content - viewport) / 2).coerceAtLeast(0f) else state.value.toFloat() +} + +val LocalOpeningTimeline = staticCompositionLocalOf { null } + /** * The scroll state of a timeline whose scrolling area starts [timelineTop] below * its view's content top. Opens where the last timeline left off, or on noon the * first time. */ @Composable -fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { +fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val memory = LocalTimelineScroll.current val topPx = with(LocalDensity.current) { timelineTop.toPx() } val remembered = memory?.midnightY val scrollState = rememberScrollState( initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, ) + val scroll = remember(scrollState) { TimelineScroll(scrollState, noonPending = remembered == null) } val currentTop by rememberUpdatedState(topPx) LaunchedEffect(scrollState) { if (remembered != null) return@LaunchedEffect @@ -44,11 +63,12 @@ fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { // Half the scroll range *is* noon: the content spans a full 24 hours, so // centring the range centres midday at whatever hour height is in force. scrollState.scrollTo(scrollState.maxValue / 2) + scroll.noonPending = false } if (memory != null) { LaunchedEffect(scrollState, memory) { snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it } } } - return scrollState + return scroll } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 903447fd..d1b27d48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text +import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,7 +60,6 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) { IconButton( onClick = onToday, modifier = Modifier - .morphChrome(ChromeSlot.Today) .semantics { contentDescription = description }, ) { val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } 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 index 10c7aabf..1faa9a86 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -7,27 +7,16 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -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.DisposableEffect +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Path -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.datetime.LocalDate @@ -41,16 +30,12 @@ import kotlinx.datetime.LocalDate * 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. - * - * A day's ground — its month cell, its timeline column — travels too, so the - * events never arrive ahead of what they sit on. */ sealed interface ViewMorphKey { data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey - data class Ground(val date: LocalDate) : ViewMorphKey companion object { /** [event]'s occurrence as drawn from [day]. */ @@ -66,9 +51,30 @@ class ViewMorphScope( val visibility: AnimatedVisibilityScope, val boundsTransform: BoundsTransform, val fadeSpec: FiniteAnimationSpec, - val clip: SharedTransitionScope.OverlayClip, + val clip: BelowTopBarClip, + val stayingPut: StayingPut, ) +/** + * The events a view has but keeps out of the switch, being scrolled out of + * sight: the other view leaves them untagged too, or its half would travel to + * nowhere and snap into place at the end. + */ +class StayingPut { + val keys = mutableStateSetOf() +} + +/** Marks [key] as staying put while [stays], for as long as it is composed. */ +@Composable +fun StayPut(key: ViewMorphKey.Event, stays: Boolean) { + val stayingPut = LocalViewMorph.current?.stayingPut ?: return + if (!stays) return + DisposableEffect(stayingPut, key) { + stayingPut.keys += key + onDispose { stayingPut.keys -= key } + } +} + /** * Keeps what travels below the top bar, which every view has at the same height: * the overlay draws above the whole screen, so an event scrolled under the bar @@ -118,6 +124,7 @@ fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) { @Composable fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { val morph = LocalViewMorph.current ?: return this + if (key in morph.stayingPut.keys) return this return with(morph.shared) { this@morphEvent.sharedBounds( sharedContentState = rememberSharedContentState(key), @@ -145,121 +152,15 @@ fun Modifier.morphDay(date: LocalDate): Modifier { exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, clipInOverlayDuringTransition = morph.clip, - // Over the grounds, which a month cell's number sits inside. zIndexInOverlay = 1f, ) } } -/** Tags a day's ground, under the events and numbers that travel with it. */ +/** The view switch's travel, on the same spring as the timeline's columns resizing. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun Modifier.morphGround(date: LocalDate): Modifier { - val morph = LocalViewMorph.current ?: return this - return with(morph.shared) { - this@morphGround.sharedBounds( - sharedContentState = rememberSharedContentState(ViewMorphKey.Ground(date)), - animatedVisibilityScope = morph.visibility, - enter = fadeIn(morph.fadeSpec), - exit = fadeOut(morph.fadeSpec), - boundsTransform = morph.boundsTransform, - clipInOverlayDuringTransition = morph.clip, - resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - ) - } -} - -/** - * The on-screen window of each day's timeline column, painted like the column - * behind the scrolling one: a whole 24-hour column can't travel, its window can. - * Nothing is drawn while there is no morph to take part in. - */ -@Composable -fun TimelineGrounds( - days: List, - scrollState: ScrollState, - hourHeight: Dp, - columnGap: Dp, - modifier: Modifier = Modifier, -) { - if (LocalViewMorph.current == null) return - val showHourGrid = LocalShowHourGrid.current - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val cell = MaterialTheme.colorScheme.surfaceContainer - val ground = if (showHourGrid) MaterialTheme.colorScheme.surface else cell - Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(columnGap)) { - days.forEach { day -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .morphGround(day) - .clipToBounds() - .background(ground) - .hourGridCells(showHourGrid, hourPx, cell) { scrollState.value.toFloat() }, - ) - } - } -} - -/** - * The pieces of the top bar and the FAB every view has (#184). Those drawn the - * same in each view hold still through a switch instead of fading out and back - * in; the title and the view pill, which differ, cross-fade in place. - */ -enum class ChromeSlot(val identical: Boolean) { - Menu(identical = true), - Search(identical = true), - Today(identical = true), - Create(identical = true), - Title(identical = false), - Pill(identical = false), -} - -/** - * Provides the view morph to a screen's top bar or FAB only while [enabled]: - * with its drawer open or opening, what travels would be drawn over the drawer. - */ -@Composable -fun ViewMorphChrome(enabled: Boolean, content: @Composable () -> Unit) = ViewMorphPage(enabled, content) - -/** Tags a piece of the top bar or the FAB; see [ChromeSlot]. */ -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun Modifier.morphChrome(slot: ChromeSlot): Modifier { - val morph = LocalViewMorph.current ?: return this - return with(morph.shared) { - val state = rememberSharedContentState(slot) - if (slot.identical) { - this@morphChrome.sharedElement( - sharedContentState = state, - animatedVisibilityScope = morph.visibility, - boundsTransform = morph.boundsTransform, - zIndexInOverlay = CHROME_Z_INDEX, - ) - } else { - this@morphChrome.sharedBounds( - sharedContentState = state, - animatedVisibilityScope = morph.visibility, - enter = fadeIn(morph.fadeSpec), - exit = fadeOut(morph.fadeSpec), - boundsTransform = morph.boundsTransform, - // Each side keeps its own size: a title squeezed to the other's - // width would ellipsise and unfold on the way. - resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.None, Alignment.CenterStart), - zIndexInOverlay = CHROME_Z_INDEX, - ) - } - } -} - -/** Above the events and dates, which pass under the top bar on their way. */ -private const val CHROME_Z_INDEX = 2f - -/** 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() + val spec = rememberViewSwitchSpec() return remember(spec) { BoundsTransform { _, _ -> spec } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt index 9d8d0d04..b33d28c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -12,7 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp /** @@ -41,8 +40,7 @@ fun ViewSwitcherPill( onClick = onCycle, shape = MaterialTheme.shapes.large, modifier = modifier - .padding(end = trailingInset) - .morphChrome(ChromeSlot.Pill), + .padding(end = trailingInset), ) { // The label cross-fades and the pill eases to its width, so cycling // views turns it over rather than snapping it (#184). @@ -52,7 +50,6 @@ fun ViewSwitcherPill( (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) }, contentAlignment = Alignment.Center, - label = "view-pill", ) { label -> Text(label) } 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 b8ac85cd..953066ca 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 @@ -8,15 +8,19 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.rememberUpdatedState +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus 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.morphGround import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight @@ -38,7 +42,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -66,21 +69,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -94,12 +86,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds @@ -128,7 +116,6 @@ import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope -import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.semantics.customActions @@ -160,14 +147,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure -import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -178,7 +159,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -207,16 +187,9 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun MonthScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -239,10 +212,6 @@ fun MonthScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling @@ -398,135 +367,96 @@ fun MonthScreen( } } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the month swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - MonthTopBar( - title = topBarTitle, - shortTitle = topBarShortTitle, - titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), - selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } + PublishChrome( + ScreenChrome( + title = topBarTitle, + shortTitle = topBarShortTitle, + titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), + drawerDate = LocalDate(titleMonth.year, titleMonth.month, 1), + awayFromToday = !isOnCurrentMonth, + todayText = stringResource(R.string.month_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + onCreate = { + // Split has a selected day; the other styles anchor on today when + // its month is shown, else the 1st. + onCreateEvent( + when { + viewStyle == MonthViewStyle.Split -> selectedDate + isOnCurrentMonth -> today + else -> LocalDate(titleMonth.year, titleMonth.month, 1) + }, + null, + ) }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = !isOnCurrentMonth && !todayInToolbar, - todayText = stringResource(R.string.month_today_action), - onToday = jumpToToday, - onCreate = { - // Split has a selected day; the other styles anchor on - // today when its month is shown, else the 1st. - onCreateEvent( - when { - viewStyle == MonthViewStyle.Split -> selectedDate - isOnCurrentMonth -> today - else -> LocalDate(titleMonth.year, titleMonth.month, 1) - }, - null, - ) - }, - ) - } - }, - ) { innerPadding -> - // Hoisted above every style's grid: a dragged chip is drawn in an - // overlay, clear of the week row's and the list viewport's clips. - val chipDrag = rememberMonthDragController() - // Undo moves the event back, so the chip travels back too. The - // signal comes from the write, not from the chip that offers it. - OnUndoStarted { chipDrag.beginUndo() } - Box( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay, clear of the week row's and the list viewport's clips. + val chipDrag = rememberMonthDragController() + // Undo moves the event back, so the chip travels back too. The + // signal comes from the write, not from the chip that offers it. + OnUndoStarted { chipDrag.beginUndo() } + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalMonthDrag provides chipDrag, ) { - Column(modifier = Modifier.fillMaxSize()) { - WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) - CompositionLocalProvider( - LocalDimCutoff provides dimCutoff, - LocalMonthDrag provides chipDrag, - ) { - if (scrolling) { - ContinuousMonthContent( - state = continuousState, - listState = listState, - dense = dense, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } else if (viewStyle == MonthViewStyle.Split) { - SplitMonthContent( - state = state, - selected = selectedDate, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - // A tapped leading/trailing day follows to its own - // month, pager and all — straight away, or a swipe - // still settling would land and take the tap back. - onSelectDay = { date -> - viewModel.selectDate(date) - jumpToMonth(YearMonth(date.year, date.month)) - }, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = { onCreateEvent(it, null) }, - ) - } else { - MonthContent( - state = state, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } else if (viewStyle == MonthViewStyle.Split) { + SplitMonthContent( + state = state, + selected = selectedDate, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + // A tapped leading/trailing day follows to its own + // month, pager and all — straight away, or a swipe + // still settling would land and take the tap back. + onSelectDay = { date -> + viewModel.selectDate(date) + jumpToMonth(YearMonth(date.year, date.month)) + }, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) + } else { + MonthContent( + state = state, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) } } - MonthDragOverlay(chipDrag) - } + } + MonthDragOverlay(chipDrag) } } } @@ -795,62 +725,6 @@ private fun ContinuousMonthContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MonthTopBar( - title: String, - shortTitle: String, - titleDate: LocalDate, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = titleDate, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - @Composable internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() @@ -2206,7 +2080,6 @@ private fun MonthWeekRow( .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) - .morphGround(d) .background( color = when { inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt index 35c43cfe..ae527670 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt @@ -20,8 +20,13 @@ internal class TimelineMorph( ) { val progress = Animatable(0f) + /** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */ + val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count) + /** Where each day's events come from: the page it was on, else the page it goes to. */ fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To enum class Side { From, To } } + +private const val LONG_SWITCH_RATIO = 3 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 c5ea80a1..14c00d8e 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 @@ -8,9 +8,16 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight import kotlinx.coroutines.flow.StateFlow @@ -19,7 +26,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -38,29 +44,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -68,14 +62,11 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import de.jeanlucmakiola.calendula.ui.common.morphChrome -import de.jeanlucmakiola.calendula.ui.common.ViewMorphChrome -import de.jeanlucmakiola.calendula.ui.common.ChromeSlot import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.StayPut import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import androidx.compose.runtime.withFrameNanos -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.SideEffect import de.jeanlucmakiola.calendula.ui.common.laneSlot @@ -109,13 +100,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING @@ -149,16 +135,15 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines 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.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec -import de.jeanlucmakiola.calendula.ui.common.TimelineGrounds 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.rememberTimelineScrollState +import de.jeanlucmakiola.calendula.ui.common.LocalOpeningTimeline import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion @@ -177,7 +162,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @@ -228,15 +212,9 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp { @Composable fun TimelineScreen( view: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, weekViewModel: WeekViewModel = hiltViewModel(), multiDayViewModel: MultiDayViewModel = hiltViewModel(), @@ -248,15 +226,9 @@ fun TimelineScreen( PagedTimelineScreen( view = view, viewModels = viewModels, - onSelectView = onSelectView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, modifier = modifier, ) } @@ -311,15 +283,9 @@ private const val MORPH_DATA_WAIT_MS = 150L private fun PagedTimelineScreen( view: CalendarView, viewModels: TimelineViewModels, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List, - drawerViewOrder: List, - todayInToolbar: Boolean, modifier: Modifier, ) { // The view on screen, which trails [view] while the columns resize into it. @@ -333,18 +299,20 @@ private fun PagedTimelineScreen( // Opens on the focused date, then carries it along as the pages move. val focus = LocalViewFocus.current EnterOnFocus(viewModel) { viewModel.jumpTo(it) } - val loadedState by viewModel.state.collectAsStateWithLifecycle() + // Keyed on the view model: collected unkeyed, a switch would read the view + // before's anchor and grid for a frame and open its pager. + val loadedState by key(viewModel) { viewModel.state.collectAsStateWithLifecycle() } // A view not shown before has nothing loaded yet; the one before it stands // in, as its pages load their own days anyway, rather than a skeleton. var lastState by remember { mutableStateOf(WeekUiState.Loading) } val state = if (loadedState is WeekUiState.Loading) lastState else loadedState SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } - val anchor by viewModel.anchor.collectAsStateWithLifecycle() - val rule by viewModel.rule.collectAsStateWithLifecycle() + val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } + val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } val position = remember(anchor, rule) { positionOf(anchor, rule) } val grid = position.grid val anchorPage = position.page - val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() } // The instant before which an event counts as completed, or null when dimming // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -353,10 +321,6 @@ private fun PagedTimelineScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) - // The bar and FAB hold still through a view switch, but not over an open drawer. - val chromeMorph = drawerState.currentValue == DrawerValue.Closed && - drawerState.targetValue == DrawerValue.Closed val scope = rememberCoroutineScope() // A new grid numbers the pages differently, so it gets a pager of its own, @@ -366,6 +330,8 @@ private fun PagedTimelineScreen( rememberPagerState(initialPage = anchorPage) { grid.pageCount } } val pageSpec = rememberCalendarPageSpec() + val switchSpec = rememberViewSwitchSpec() + val longSwitchSpec = rememberViewSwitchSpec(long = true) val reduceMotion = rememberReduceMotion() // The pager leads and the anchor follows once it settles, so this only // catches the anchor moving on its own. Snapped, since that is a correction @@ -415,7 +381,7 @@ private fun PagedTimelineScreen( try { withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } morph = switch - switch.progress.animateTo(1f, pageSpec) + switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) } finally { handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() shown = view @@ -450,83 +416,46 @@ private fun PagedTimelineScreen( } val jumpToToday = { jumpToDate(today) } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the week swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = view, - currentDate = weekStart, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { target -> - onSelectView(target) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, + val locale = currentLocale() + val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) { + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true) + } + PublishChrome( + ScreenChrome( + title = title, + shortTitle = shortTitle, + titleDate = weekStart, + drawerDate = weekStart, + awayFromToday = !isOnCurrentWeek, + todayText = stringResource(titleKind.todayText), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + // Anchor on today when it's in view, else the page's first day. + onCreate = { onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + WeekContent( + state = state, + pagerState = pagerState, + grid = grid, + kind = kind, + morph = morph, + handoffStrip = handoffStrip, + span = viewModel::span, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - ViewMorphChrome(chromeMorph) { - WeekTopBar( - kind = titleKind, - weekStart = weekStart, - weekEnd = weekEnd, - currentYear = today.year, - selectedView = view, - onCycleView = { onSelectView(view.next(quickSwitchViews)) }, - quickSwitchViews = quickSwitchViews, - onOpenDrawer = { scope.launch { drawerState.open() } }, - onOpenSearch = onOpenSearch, - onJumpToDate = jumpToDate, - showTodayButton = todayInToolbar, - onToday = jumpToToday, - ) - } - }, - floatingActionButton = { - ViewMorphChrome(chromeMorph) { - CalendarFabColumn( - todayVisible = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(titleKind.todayText), - onToday = jumpToToday, - onCreate = { - // Anchor on today when it's in view, else the page's first day. - onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) - }, - ) - } - }, - ) { innerPadding -> - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - WeekContent( - state = state, - pagerState = pagerState, - grid = grid, - kind = kind, - morph = morph, - handoffStrip = handoffStrip, - span = viewModel::span, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } } } } @@ -552,7 +481,8 @@ private fun WeekContent( // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over // from the timeline view before this one. val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp - val scrollState = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val timelineScroll = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val scrollState = timelineScroll.state // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -571,22 +501,37 @@ private fun WeekContent( } Box(modifier = modifier) { - // Loading and failure concern the provider as a whole, so they stand in - // for the pager; a page still loading its own week handles that itself. - AnimatedContent( - targetState = state, - modifier = Modifier.fillMaxSize(), - contentKey = { it::class }, - transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, - label = "week-state", - ) { s -> - when (s) { - WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) - is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> if (morph != null) { - TimelineMorphFrame( - morph = morph, + CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) { + // Loading and failure concern the provider as a whole, so they stand in + // for the pager; a page still loading its own week handles that itself. + AnimatedContent( + targetState = state, + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "week-state", + ) { s -> + when (s) { + WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> if (morph != null) { + TimelineMorphFrame( + morph = morph, + today = s.today, + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } else WeekPager( + pagerState = pagerState, + grid = grid, + kind = kind, + span = span, today = s.today, + initialStripHeight = handoffStrip ?: s.allDayStripHeight(), scrollState = scrollState, dragController = dragController, onEventClick = onEventClick, @@ -594,20 +539,7 @@ private fun WeekContent( onCreateAt = onCreateAt, onDrop = onDrop, ) - } else WeekPager( - pagerState = pagerState, - grid = grid, - kind = kind, - span = span, - today = s.today, - initialStripHeight = handoffStrip ?: s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) + } } } TimelineDragOverlay(dragController) @@ -934,68 +866,6 @@ private fun TimelinePageContent( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun WeekTopBar( - kind: TimelineKind, - weekStart: LocalDate, - weekEnd: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(kind, weekStart, weekEnd, locale, currentYear) { - kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = false) to - kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = weekStart, - onJumpToDate = onJumpToDate, - shortTitle = shortTitle, - ) - }, - navigationIcon = { - IconButton(onClick = onOpenDrawer, modifier = Modifier.morphChrome(ChromeSlot.Menu)) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(R.string.month_open_menu), - ) - } - }, - actions = { - TodayAction(show = showTodayButton, onToday = onToday) - IconButton(onClick = onOpenSearch, modifier = Modifier.morphChrome(ChromeSlot.Search)) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.search_action), - ) - } - ViewSwitcherPill( - current = selectedView, - cycle = quickSwitchViews, - onCycle = onCycleView, - ) - }, - // Deliberately flat: M3 lifts the bar to mark content scrolling under - // it, but here the bar meets the header on the same surface and the - // tint is what makes that seam look like a separate block (#186). - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - @Composable private fun WeekDayHeader( geometry: ColumnGeometry, @@ -1246,16 +1116,14 @@ private fun Timeline( derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } } val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP - TimelineGrounds( - days = geometry.dates, - scrollState = scrollState, - hourHeight = hourHeight, - columnGap = COLUMN_GAP, - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)), - ) + // What the scroll shows, known before it is laid out: a view switch + // picks what travels on its first frame. + val opening = LocalOpeningTimeline.current + val viewportPx = with(density) { maxHeight.toPx() } + val contentPx = with(density) { totalHeight.toPx() } + val visibleTop: () -> Float = remember(opening, scrollState, viewportPx, contentPx) { + { opening?.top(viewportPx, contentPx) ?: scrollState.value.toFloat() } + } // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content // scrolls inside it, so the soft corners are permanent at any scroll @@ -1300,6 +1168,8 @@ private fun Timeline( today = today, hourHeight = hourHeight, columnWidth = columnWidth, + visibleTop = visibleTop, + viewportHeight = viewportPx, dragController = dragController, onEventClick = onEventClick, onCreateAt = onCreateAt, @@ -1319,13 +1189,17 @@ private fun DayColumnCard( today: LocalDate, hourHeight: Dp, columnWidth: Dp, + visibleTop: () -> Float, + viewportHeight: Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val density = LocalDensity.current + val hourPx = with(density) { hourHeight.toPx() } + val morphing = LocalViewMorph.current != null val showHourGrid = LocalShowHourGrid.current val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. @@ -1401,6 +1275,17 @@ private fun DayColumnCard( val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) .coerceAtLeast(0.dp) + // Only a block wholly on screen travels in a view switch: the + // rest would cross the header on their way. They fade with + // their screen, and the other view leaves them be too. + val travels = morphing && rememberInViewport( + visibleTop = visibleTop, + viewportHeight = viewportHeight, + top = with(density) { (top + topInset).toPx() }, + bottom = with(density) { (top + height - bottomInset).toPx() }, + ) + val morphKey = ViewMorphKey.event(block.event, date) + StayPut(morphKey, stays = morphing && !travels) EventBlock( block = block, dark = dark, @@ -1415,7 +1300,7 @@ private fun DayColumnCard( modifier = Modifier .laneSlot(place) .offset(y = place.y + topInset) - .morphEvent(ViewMorphKey.event(block.event, date)) + .then(if (travels) Modifier.morphEvent(morphKey) else Modifier) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) @@ -1429,6 +1314,15 @@ private fun DayColumnCard( } } +/** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ +@Composable +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: Float, top: Float, bottom: Float): Boolean { + val inView by remember(visibleTop, viewportHeight, top, bottom) { + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight } } + } + return inView +} + @Composable private fun EventBlock( block: TimedBlock, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 2acd4ef0..0768529e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -14,6 +14,9 @@ import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.PageStateCache import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -164,6 +167,20 @@ abstract class PagedTimelineViewModel( fun goToToday() = jumpTo(todayDate) + /** + * Moves to [date] and waits for its page to load, so a switch into this view + * opens on its events instead of watching them arrive mid-way (#184). + */ + suspend fun prepare(date: LocalDate) { + jumpTo(date) + val position = positionOf(date, rule.value) + val page = DaySpan(position.grid.startForPage(position.page), position.grid.dayCount) + coroutineScope { + launch { state.first { it !is WeekUiState.Loading } } + span(page).first { it !is WeekUiState.Loading } + } + } + private fun buildState( span: DaySpan, calendars: List, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f31a061f..f444241a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -111,9 +111,10 @@ gesture finishes from its scaled preview. - **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`, an event shown in both views travels from its old bounds to its new ones, keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a - pager's settled page carries the tags. The top bar's shared pieces and the - create FAB are tagged too (`morphChrome`), so they hold still while the - rest cross-fades — except over an open drawer. + pager's settled page carries the tags. The drawer, top bar and FAB are not + per screen: CalendarHost draws them once, above the switch, from what the + screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a + switch only changes what they say. A tapped reminder notification routes through `MainActivity` (`singleTop` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap.