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