Share one top bar across views and smooth the view switches (#184)

This commit is contained in:
2026-09-27 21:12:22 +02:00
parent 254fafe42b
commit 62ea5ce9d3
15 changed files with 677 additions and 816 deletions
@@ -9,16 +9,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.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.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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen 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.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip 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.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion 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.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -130,14 +150,33 @@ fun CalendarHost(
} }
val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline
SideEffect { heldTimeline = timelineView } SideEffect { heldTimeline = timelineView }
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// The date every view opens on and carries along (#184); tapping a day // The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there. // anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } 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<WeekViewModel>(),
hiltViewModel<MultiDayViewModel>(),
hiltViewModel<DayViewModel>(),
)
val switchScope = rememberCoroutineScope()
val switchTo: (CalendarView, (List<CalendarView>) -> List<CalendarView>) -> 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 -> val onOpenDay: (LocalDate) -> Unit = { date ->
focus.date = 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 // The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -392,6 +431,9 @@ fun CalendarHost(
val morphBounds = rememberViewMorphBoundsTransform() val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec() val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() } val morphClip = remember { BelowTopBarClip() }
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) + val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
@@ -404,10 +446,60 @@ fun CalendarHost(
// (#239); nothing else in the calendar surfaces navigates here. // (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true }, LocalManageCalendars provides { showCalendars = true },
) { ) {
// 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( SharedTransitionLayout(
Modifier Modifier
.padding(innerPadding)
.fillMaxSize() .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 // 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 // are one timeline that resizes between them itself (#184), so switching
@@ -419,52 +511,38 @@ fun CalendarHost(
) { screen -> ) { screen ->
val sharedScope = this@SharedTransitionLayout val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, 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) 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) { when (screen) {
ViewScreen.Timeline -> TimelineScreen( ViewScreen.Timeline -> TimelineScreen(
view = timelineView, view = timelineView,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
ViewScreen.Month -> MonthScreen( ViewScreen.Month -> MonthScreen(
selectedView = CalendarView.Month,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
ViewScreen.Agenda -> AgendaScreen( ViewScreen.Agenda -> AgendaScreen(
selectedView = CalendarView.Agenda,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
} }
} }
} }
} }
} }
}
}
// Scope prompt + confirmation/undo snackbar for a dropped event, declared // Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it. // right after the calendar views so later overlays cover it.
@@ -670,3 +748,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
private fun localToday(): LocalDate = private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date 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
@@ -1,8 +1,14 @@
package de.jeanlucmakiola.calendula.ui.agenda 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.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee 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.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS 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.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -91,16 +77,10 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AgendaScreen( fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
@@ -117,10 +97,6 @@ fun AgendaScreen(
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.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() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -152,111 +128,78 @@ fun AgendaScreen(
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it } (state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
} }
ModalNavigationDrawer( PublishChrome(
drawerState = drawerState, ScreenChrome(
drawerContent = { title = stringResource(R.string.view_agenda),
CalendarDrawer( titleDate = null,
currentView = selectedView, drawerDate = anchor,
currentDate = anchor, awayFromToday = !isOnToday || (scrolledAway && successState != null),
drawerState = drawerState, todayText = stringResource(R.string.agenda_today_action),
viewOrder = drawerViewOrder, ),
onSelectView = { view -> ScreenActions(
onSelectView(view) onToday = jumpToToday,
scope.launch { drawerState.close() } onJumpToDate = { target ->
}, focus?.date = target
onJumpToDate = { target -> viewModel.goToDate(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,
)
}
}, },
floatingActionButton = { onCreate = { onCreateEvent(anchor, null) },
ViewMorphChrome(chromeMorph) { ),
CalendarFabColumn( )
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar, ScreenBody(modifier = modifier) { innerPadding ->
todayText = stringResource(R.string.agenda_today_action), Column(
onToday = jumpToToday, modifier = Modifier
onCreate = { onCreateEvent(anchor, null) }, .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(
) { innerPadding -> verticalAlignment = Alignment.CenterVertically,
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 },
modifier = Modifier 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<CalendarView>,
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,
),
)
}
@@ -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<ScreenChrome?>(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<ChromeSlot?> { 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<CalendarView>,
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())
}
}
@@ -15,7 +15,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -49,7 +48,7 @@ fun CalendarFabColumn(
text = { Text(todayText) }, text = { Text(todayText) },
) )
} }
FloatingActionButton(onClick = onCreate, modifier = Modifier.morphChrome(ChromeSlot.Create)) { FloatingActionButton(onClick = onCreate) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title), contentDescription = stringResource(R.string.event_edit_new_title),
@@ -1,6 +1,9 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent 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.SizeTransform
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -38,7 +41,8 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate]. * drawer uses and reporting the chosen day through [onJumpToDate].
* *
* [currentDate] seeds the picker with whatever the bar is currently naming (the * [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 * [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 * bar hands the title slot (#165). Either way the line clamps to one and
@@ -47,7 +51,7 @@ import kotlinx.datetime.LocalDate
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
title: String, title: String,
currentDate: LocalDate, currentDate: LocalDate?,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortTitle: String = title, shortTitle: String = title,
@@ -56,10 +60,10 @@ fun CalendarTitleButton(
Row( Row(
modifier = modifier modifier = modifier
.morphChrome(ChromeSlot.Title)
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.clickable( .clickable(
enabled = currentDate != null,
onClickLabel = stringResource(R.string.drawer_jump_to_date), onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button, role = Role.Button,
) { showDatePicker = true } ) { showDatePicker = true }
@@ -86,7 +90,6 @@ fun CalendarTitleButton(
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
}, },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
label = "calendar-title",
) { text -> ) { text ->
Text( Text(
text = text, text = text,
@@ -96,14 +99,20 @@ fun CalendarTitleButton(
) )
} }
} }
Icon( AnimatedVisibility(
imageVector = Icons.Default.ArrowDropDown, visible = currentDate != null,
contentDescription = null, enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
tint = MaterialTheme.colorScheme.onSurfaceVariant, exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
) ) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
if (showDatePicker) { if (showDatePicker && currentDate != null) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
initial = currentDate, initial = currentDate,
onConfirm = { onConfirm = {
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
@Composable @Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> = fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec() 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 <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
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
@@ -2,9 +2,13 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
@@ -24,19 +28,34 @@ class TimelineScrollMemory {
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null } val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { 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<TimelineScroll?> { null }
/** /**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below * 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 * its view's content top. Opens where the last timeline left off, or on noon the
* first time. * first time.
*/ */
@Composable @Composable
fun rememberTimelineScrollState(timelineTop: Dp): ScrollState { fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val memory = LocalTimelineScroll.current val memory = LocalTimelineScroll.current
val topPx = with(LocalDensity.current) { timelineTop.toPx() } val topPx = with(LocalDensity.current) { timelineTop.toPx() }
val remembered = memory?.midnightY val remembered = memory?.midnightY
val scrollState = rememberScrollState( val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
) )
val scroll = remember(scrollState) { TimelineScroll(scrollState, noonPending = remembered == null) }
val currentTop by rememberUpdatedState(topPx) val currentTop by rememberUpdatedState(topPx)
LaunchedEffect(scrollState) { LaunchedEffect(scrollState) {
if (remembered != null) return@LaunchedEffect 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 // 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. // centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2) scrollState.scrollTo(scrollState.maxValue / 2)
scroll.noonPending = false
} }
if (memory != null) { if (memory != null) {
LaunchedEffect(scrollState, memory) { LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it } snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
} }
} }
return scrollState return scroll
} }
@@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -60,7 +60,6 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
IconButton( IconButton(
onClick = onToday, onClick = onToday,
modifier = Modifier modifier = Modifier
.morphChrome(ChromeSlot.Today)
.semantics { contentDescription = description }, .semantics { contentDescription = description },
) { ) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
@@ -7,27 +7,16 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.geometry.Rect
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate 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, * 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 * 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. * 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 { sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey data class Day(val date: LocalDate) : ViewMorphKey
data class Ground(val date: LocalDate) : ViewMorphKey
companion object { companion object {
/** [event]'s occurrence as drawn from [day]. */ /** [event]'s occurrence as drawn from [day]. */
@@ -66,9 +51,30 @@ class ViewMorphScope(
val visibility: AnimatedVisibilityScope, val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform, val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>, val fadeSpec: FiniteAnimationSpec<Float>,
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<ViewMorphKey.Event>()
}
/** 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: * 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 * 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 @Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this val morph = LocalViewMorph.current ?: return this
if (key in morph.stayingPut.keys) return this
return with(morph.shared) { return with(morph.shared) {
this@morphEvent.sharedBounds( this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key), sharedContentState = rememberSharedContentState(key),
@@ -145,121 +152,15 @@ fun Modifier.morphDay(date: LocalDate): Modifier {
exit = fadeOut(morph.fadeSpec), exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform, boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip, clipInOverlayDuringTransition = morph.clip,
// Over the grounds, which a month cell's number sits inside.
zIndexInOverlay = 1f, 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) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @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<LocalDate>,
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 { fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = MaterialTheme.motionScheme.defaultSpatialSpec<Rect>() val spec = rememberViewSwitchSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } } return remember(spec) { BoundsTransform { _, _ -> spec } }
} }
@@ -12,7 +12,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** /**
@@ -41,8 +40,7 @@ fun ViewSwitcherPill(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier modifier = modifier
.padding(end = trailingInset) .padding(end = trailingInset),
.morphChrome(ChromeSlot.Pill),
) { ) {
// The label cross-fades and the pill eases to its width, so cycling // The label cross-fades and the pill eases to its width, so cycling
// views turns it over rather than snapping it (#184). // views turns it over rather than snapping it (#184).
@@ -52,7 +50,6 @@ fun ViewSwitcherPill(
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false)) (fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
label = "view-pill",
) { label -> ) { label ->
Text(label) Text(label)
} }
@@ -8,15 +8,19 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.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.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent 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.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.pagedHeight
@@ -38,7 +42,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown 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.selection.selectable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@@ -94,12 +86,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds 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_FADE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.semantics.customActions 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.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle 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.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.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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors 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.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
@@ -207,16 +187,9 @@ import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MonthScreen( fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
@@ -239,10 +212,6 @@ fun MonthScreen(
derivedStateOf { if (dimCompleted) nowState.value else null } 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 scope = rememberCoroutineScope()
val scrolling = viewStyle.isScrolling val scrolling = viewStyle.isScrolling
@@ -398,135 +367,96 @@ fun MonthScreen(
} }
} }
ModalNavigationDrawer( PublishChrome(
drawerState = drawerState, ScreenChrome(
// Open only via the menu button — edge-swipe would fight the month swipe. title = topBarTitle,
gesturesEnabled = drawerState.isOpen, shortTitle = topBarShortTitle,
drawerContent = { titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
CalendarDrawer( drawerDate = LocalDate(titleMonth.year, titleMonth.month, 1),
currentView = selectedView, awayFromToday = !isOnCurrentMonth,
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), todayText = stringResource(R.string.month_today_action),
drawerState = drawerState, ),
viewOrder = drawerViewOrder, ScreenActions(
onSelectView = { view -> onToday = jumpToToday,
onSelectView(view) onJumpToDate = jumpToDate,
scope.launch { drawerState.close() } onCreate = {
}, // Split has a selected day; the other styles anchor on today when
onJumpToDate = { target -> // its month is shown, else the 1st.
jumpToDate(target) onCreateEvent(
scope.launch { drawerState.close() } when {
}, viewStyle == MonthViewStyle.Split -> selectedDate
onSettings = { isOnCurrentMonth -> today
onOpenSettings() else -> LocalDate(titleMonth.year, titleMonth.month, 1)
scope.launch { drawerState.close() } },
}, null,
) )
},
) {
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,
)
}
}, },
floatingActionButton = { ),
ViewMorphChrome(chromeMorph) { )
CalendarFabColumn(
todayVisible = !isOnCurrentMonth && !todayInToolbar, ScreenBody(modifier = modifier) { innerPadding ->
todayText = stringResource(R.string.month_today_action), // Hoisted above every style's grid: a dragged chip is drawn in an
onToday = jumpToToday, // overlay, clear of the week row's and the list viewport's clips.
onCreate = { val chipDrag = rememberMonthDragController()
// Split has a selected day; the other styles anchor on // Undo moves the event back, so the chip travels back too. The
// today when its month is shown, else the 1st. // signal comes from the write, not from the chip that offers it.
onCreateEvent( OnUndoStarted { chipDrag.beginUndo() }
when { Box(
viewStyle == MonthViewStyle.Split -> selectedDate modifier = Modifier
isOnCurrentMonth -> today .padding(innerPadding)
else -> LocalDate(titleMonth.year, titleMonth.month, 1) .fillMaxSize(),
}, ) {
null, Column(modifier = Modifier.fillMaxSize()) {
) WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
}, CompositionLocalProvider(
) LocalDimCutoff provides dimCutoff,
} LocalMonthDrag provides chipDrag,
},
) { 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()) { if (scrolling) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) ContinuousMonthContent(
CompositionLocalProvider( state = continuousState,
LocalDimCutoff provides dimCutoff, listState = listState,
LocalMonthDrag provides chipDrag, dense = dense,
) { showWeekNumbers = showWeekNumbers,
if (scrolling) { onRetry = jumpToToday,
ContinuousMonthContent( onOpenDay = onOpenDay,
state = continuousState, onEventClick = onEventClick,
listState = listState, )
dense = dense, } else if (viewStyle == MonthViewStyle.Split) {
showWeekNumbers = showWeekNumbers, SplitMonthContent(
onRetry = jumpToToday, state = state,
onOpenDay = onOpenDay, selected = selectedDate,
onEventClick = onEventClick, pagerState = pagerState,
) weekStart = weekStart,
} else if (viewStyle == MonthViewStyle.Split) { monthState = viewModel::month,
SplitMonthContent( showWeekNumbers = showWeekNumbers,
state = state, onRetry = jumpToToday,
selected = selectedDate, // A tapped leading/trailing day follows to its own
pagerState = pagerState, // month, pager and all — straight away, or a swipe
weekStart = weekStart, // still settling would land and take the tap back.
monthState = viewModel::month, onSelectDay = { date ->
showWeekNumbers = showWeekNumbers, viewModel.selectDate(date)
onRetry = jumpToToday, jumpToMonth(YearMonth(date.year, date.month))
// A tapped leading/trailing day follows to its own },
// month, pager and all — straight away, or a swipe onOpenDay = onOpenDay,
// still settling would land and take the tap back. onEventClick = onEventClick,
onSelectDay = { date -> onCreateEvent = { onCreateEvent(it, null) },
viewModel.selectDate(date) )
jumpToMonth(YearMonth(date.year, date.month)) } else {
}, MonthContent(
onOpenDay = onOpenDay, state = state,
onEventClick = onEventClick, pagerState = pagerState,
onCreateEvent = { onCreateEvent(it, null) }, weekStart = weekStart,
) monthState = viewModel::month,
} else { showWeekNumbers = showWeekNumbers,
MonthContent( onRetry = jumpToToday,
state = state, onOpenDay = onOpenDay,
pagerState = pagerState, onEventClick = onEventClick,
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<CalendarView>,
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 @Composable
internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
val locale = currentLocale() val locale = currentLocale()
@@ -2206,7 +2080,6 @@ private fun MonthWeekRow(
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.morphElement(MonthMorphKey.Cell(d)) .morphElement(MonthMorphKey.Cell(d))
.morphGround(d)
.background( .background(
color = when { color = when {
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
@@ -20,8 +20,13 @@ internal class TimelineMorph(
) { ) {
val progress = Animatable(0f) 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. */ /** 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 fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
enum class Side { From, To } enum class Side { From, To }
} }
private const val LONG_SWITCH_RATIO = 3
@@ -8,9 +8,16 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState 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.DisposableEffect
import androidx.compose.runtime.mutableStateMapOf 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.rememberCalendarPageSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec
import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.jumpToPage
import de.jeanlucmakiola.calendula.ui.common.pagedHeight import de.jeanlucmakiola.calendula.ui.common.pagedHeight
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -19,7 +26,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@@ -68,14 +62,11 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier 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.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.StayPut
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import de.jeanlucmakiola.calendula.ui.common.laneSlot 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.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle 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.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView 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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING 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.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec 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.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage
import de.jeanlucmakiola.calendula.ui.common.morphDay import de.jeanlucmakiola.calendula.ui.common.morphDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState 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.morphPageActive
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
@@ -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.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -228,15 +212,9 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp {
@Composable @Composable
fun TimelineScreen( fun TimelineScreen(
view: CalendarView, view: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
weekViewModel: WeekViewModel = hiltViewModel(), weekViewModel: WeekViewModel = hiltViewModel(),
multiDayViewModel: MultiDayViewModel = hiltViewModel(), multiDayViewModel: MultiDayViewModel = hiltViewModel(),
@@ -248,15 +226,9 @@ fun TimelineScreen(
PagedTimelineScreen( PagedTimelineScreen(
view = view, view = view,
viewModels = viewModels, viewModels = viewModels,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
modifier = modifier, modifier = modifier,
) )
} }
@@ -311,15 +283,9 @@ private const val MORPH_DATA_WAIT_MS = 150L
private fun PagedTimelineScreen( private fun PagedTimelineScreen(
view: CalendarView, view: CalendarView,
viewModels: TimelineViewModels, viewModels: TimelineViewModels,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView>,
drawerViewOrder: List<CalendarView>,
todayInToolbar: Boolean,
modifier: Modifier, modifier: Modifier,
) { ) {
// The view on screen, which trails [view] while the columns resize into it. // 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. // Opens on the focused date, then carries it along as the pages move.
val focus = LocalViewFocus.current val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { viewModel.jumpTo(it) } 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 // 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. // in, as its pages load their own days anyway, rather than a skeleton.
var lastState by remember { mutableStateOf<WeekUiState>(WeekUiState.Loading) } var lastState by remember { mutableStateOf<WeekUiState>(WeekUiState.Loading) }
val state = if (loadedState is WeekUiState.Loading) lastState else loadedState val state = if (loadedState is WeekUiState.Loading) lastState else loadedState
SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState }
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() }
val rule by viewModel.rule.collectAsStateWithLifecycle() val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() }
val position = remember(anchor, rule) { positionOf(anchor, rule) } val position = remember(anchor, rule) { positionOf(anchor, rule) }
val grid = position.grid val grid = position.grid
val anchorPage = position.page 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 // The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the // is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick). // 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 } 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 scope = rememberCoroutineScope()
// A new grid numbers the pages differently, so it gets a pager of its own, // 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 } rememberPagerState(initialPage = anchorPage) { grid.pageCount }
} }
val pageSpec = rememberCalendarPageSpec() val pageSpec = rememberCalendarPageSpec()
val switchSpec = rememberViewSwitchSpec<Float>()
val longSwitchSpec = rememberViewSwitchSpec<Float>(long = true)
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
// The pager leads and the anchor follows once it settles, so this only // 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 // catches the anchor moving on its own. Snapped, since that is a correction
@@ -415,7 +381,7 @@ private fun PagedTimelineScreen(
try { try {
withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } }
morph = switch morph = switch
switch.progress.animateTo(1f, pageSpec) switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec)
} finally { } finally {
handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight()
shown = view shown = view
@@ -450,83 +416,46 @@ private fun PagedTimelineScreen(
} }
val jumpToToday = { jumpToDate(today) } val jumpToToday = { jumpToDate(today) }
ModalNavigationDrawer( val locale = currentLocale()
drawerState = drawerState, val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
// Open only via the menu button — edge-swipe would fight the week swipe. titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
gesturesEnabled = drawerState.isOpen, titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
drawerContent = { }
CalendarDrawer( PublishChrome(
currentView = view, ScreenChrome(
currentDate = weekStart, title = title,
drawerState = drawerState, shortTitle = shortTitle,
viewOrder = drawerViewOrder, titleDate = weekStart,
onSelectView = { target -> drawerDate = weekStart,
onSelectView(target) awayFromToday = !isOnCurrentWeek,
scope.launch { drawerState.close() } todayText = stringResource(titleKind.todayText),
}, ),
onJumpToDate = { target -> ScreenActions(
jumpToDate(target) onToday = jumpToToday,
scope.launch { drawerState.close() } onJumpToDate = jumpToDate,
}, // Anchor on today when it's in view, else the page's first day.
onSettings = { onCreate = { onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) },
onOpenSettings() ),
scope.launch { drawerState.close() } )
},
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 // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over
// from the timeline view before this one. // from the timeline view before this one.
val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp 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 // Above the pager: a page change mid-drag would strand the floating block
// inside the outgoing page. // inside the outgoing page.
@@ -571,22 +501,37 @@ private fun WeekContent(
} }
Box(modifier = modifier) { Box(modifier = modifier) {
// Loading and failure concern the provider as a whole, so they stand in CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
// for the pager; a page still loading its own week handles that itself. // Loading and failure concern the provider as a whole, so they stand in
AnimatedContent( // for the pager; a page still loading its own week handles that itself.
targetState = state, AnimatedContent(
modifier = Modifier.fillMaxSize(), targetState = state,
contentKey = { it::class }, modifier = Modifier.fillMaxSize(),
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, contentKey = { it::class },
label = "week-state", transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
) { s -> label = "week-state",
when (s) { ) { s ->
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) when (s) {
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
is WeekUiState.Success -> if (morph != null) { is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
TimelineMorphFrame( is WeekUiState.Success -> if (morph != null) {
morph = morph, 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, today = s.today,
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState, scrollState = scrollState,
dragController = dragController, dragController = dragController,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -594,20 +539,7 @@ private fun WeekContent(
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = onDrop, 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) 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<CalendarView>,
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 @Composable
private fun WeekDayHeader( private fun WeekDayHeader(
geometry: ColumnGeometry, geometry: ColumnGeometry,
@@ -1246,16 +1116,14 @@ private fun Timeline(
derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } 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 val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP
TimelineGrounds( // What the scroll shows, known before it is laid out: a view switch
days = geometry.dates, // picks what travels on its first frame.
scrollState = scrollState, val opening = LocalOpeningTimeline.current
hourHeight = hourHeight, val viewportPx = with(density) { maxHeight.toPx() }
columnGap = COLUMN_GAP, val contentPx = with(density) { totalHeight.toPx() }
modifier = Modifier val visibleTop: () -> Float = remember(opening, scrollState, viewportPx, contentPx) {
.fillMaxSize() { opening?.top(viewportPx, contentPx) ?: scrollState.value.toFloat() }
.padding(end = TIMELINE_CONTENT_END_INSET) }
.clip(RoundedCornerShape(16.dp)),
)
// Scrolls on the same state as the gutter and the other pages, so they // Scrolls on the same state as the gutter and the other pages, so they
// all stay aligned. A static, rounded-clipped window — the content // all stay aligned. A static, rounded-clipped window — the content
// scrolls inside it, so the soft corners are permanent at any scroll // scrolls inside it, so the soft corners are permanent at any scroll
@@ -1300,6 +1168,8 @@ private fun Timeline(
today = today, today = today,
hourHeight = hourHeight, hourHeight = hourHeight,
columnWidth = columnWidth, columnWidth = columnWidth,
visibleTop = visibleTop,
viewportHeight = viewportPx,
dragController = dragController, dragController = dragController,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
@@ -1319,13 +1189,17 @@ private fun DayColumnCard(
today: LocalDate, today: LocalDate,
hourHeight: Dp, hourHeight: Dp,
columnWidth: Dp, columnWidth: Dp,
visibleTop: () -> Float,
viewportHeight: Float,
dragController: TimelineDragController, dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier, 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 showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
// Tells a settled drop when this column has caught up with it. // 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 bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset) val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp) .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( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
@@ -1415,7 +1300,7 @@ private fun DayColumnCard(
modifier = Modifier modifier = Modifier
.laneSlot(place) .laneSlot(place)
.offset(y = place.y + topInset) .offset(y = place.y + topInset)
.morphEvent(ViewMorphKey.event(block.event, date)) .then(if (travels) Modifier.morphEvent(morphKey) else Modifier)
.height(blockHeight) .height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET), .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 @Composable
private fun EventBlock( private fun EventBlock(
block: TimedBlock, block: TimedBlock,
@@ -14,6 +14,9 @@ import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -164,6 +167,20 @@ abstract class PagedTimelineViewModel(
fun goToToday() = jumpTo(todayDate) 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( private fun buildState(
span: DaySpan, span: DaySpan,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
+4 -3
View File
@@ -111,9 +111,10 @@ gesture finishes from its scaled preview.
- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`, - **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`,
an event shown in both views travels from its old bounds to its new ones, 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 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 pager's settled page carries the tags. The drawer, top bar and FAB are not
create FAB are tagged too (`morphChrome`), so they hold still while the per screen: CalendarHost draws them once, above the switch, from what the
rest cross-fades — except over an open drawer. screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a
switch only changes what they say.
A tapped reminder notification routes through `MainActivity` (`singleTop` + A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes `onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap. exactly like an event tap.