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