Merge view morph (#184)
This commit is contained in:
@@ -288,6 +288,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many days the multi-day view shows side by side (#193). */
|
||||||
|
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMultiDayCount(count: Int) {
|
||||||
|
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hours the day, week and multi-day timelines fit to the screen when they
|
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||||
* open (#322). Off by default; the hours survive switching it off.
|
* open (#322). Off by default; the hours survive switching it off.
|
||||||
@@ -309,15 +318,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many days the multi-day view shows side by side (#193). */
|
|
||||||
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
|
||||||
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setMultiDayCount(count: Int) {
|
|
||||||
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
|
|||||||
@@ -3,22 +3,36 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
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.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.mutableIntStateOf
|
||||||
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.ViewScreen
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -26,19 +40,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
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.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.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
@@ -46,8 +79,8 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
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.MultiDayScreen
|
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
@@ -55,8 +88,9 @@ import kotlin.time.Clock
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||||
|
* writes back here via [onSelectView].
|
||||||
*
|
*
|
||||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||||
@@ -73,6 +107,7 @@ import kotlin.time.Clock
|
|||||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||||
* widget's view.)
|
* widget's view.)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -104,19 +139,33 @@ fun CalendarHost(
|
|||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
|
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||||
val view = viewStack.last()
|
val view = viewStack.last()
|
||||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
// The timeline view last shown, so the timeline leaving for another screen
|
||||||
|
// keeps drawing the view it was on while it goes.
|
||||||
|
var heldTimeline by rememberSaveable {
|
||||||
|
mutableStateOf(if (view.screen == ViewScreen.Timeline) view else CalendarView.Week)
|
||||||
|
}
|
||||||
|
val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline
|
||||||
|
SideEffect { heldTimeline = timelineView }
|
||||||
|
|
||||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
// The date every view opens on and carries along (#184); tapping a day
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
// anywhere focuses it and opens the day view there.
|
||||||
|
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||||
|
|
||||||
|
val switchScope = rememberCoroutineScope()
|
||||||
|
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||||
|
navigationVersion++
|
||||||
|
viewStack = move(viewStack)
|
||||||
|
}
|
||||||
|
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
focus.navigateTo(date)
|
||||||
viewStack = viewStack.drillToDay()
|
switchTo { it.drillToDay() }
|
||||||
}
|
}
|
||||||
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||||
pendingWeekIso = date.toString()
|
focus.navigateTo(date)
|
||||||
viewStack = viewStack.drillToWeek()
|
switchTo { it.drillToWeek() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -266,13 +315,14 @@ fun CalendarHost(
|
|||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
// view so backing out returns there (then home), not to the default.
|
// view so backing out returns there (then home), not to the default.
|
||||||
LaunchedEffect(widgetNavRequest) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
if (widgetNavRequest != null) navigationVersion++
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Drill the day view in over the widget's view: drop any overlay
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// that would cover it, so the open doesn't land under Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||||
// No widget source (an external date tap) roots over the default
|
// No widget source (an external date tap) roots over the default
|
||||||
// home view, so backing out of the day returns home then exits.
|
// home view, so backing out of the day returns home then exits.
|
||||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
@@ -296,7 +346,7 @@ fun CalendarHost(
|
|||||||
// default home — so backing out returns to the default, then exits.
|
// default home — so backing out returns to the default, then exits.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = null
|
focus.navigateTo(localToday())
|
||||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
@@ -347,6 +397,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||||
|
// where the gesture let go instead of snapping back and sliding off.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
@@ -355,90 +408,129 @@ fun CalendarHost(
|
|||||||
editKey != null || showSettings || showCalendars || showBackup ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importUri != null || importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||||
|
navigationVersion++
|
||||||
viewStack = viewStack.dropLast(1)
|
viewStack = viewStack.dropLast(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the month, timeline and agenda screens is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. What both views show morphs across
|
||||||
|
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val morphBounds = rememberViewMorphBoundsTransform()
|
||||||
|
val morphFade = rememberCalendarFadeSpec()
|
||||||
|
val morphClip = remember { BelowTopBarClip() }
|
||||||
|
val stayingPut = remember { StayingPut() }
|
||||||
|
val chromeHost = remember { ChromeHost() }
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||||
|
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
|
val timelineScroll = remember { TimelineScrollMemory() }
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalEventMove provides moveScope,
|
LocalEventMove provides moveScope,
|
||||||
|
LocalTimelineScroll provides timelineScroll,
|
||||||
|
LocalViewFocus provides focus,
|
||||||
// The failure state's way out when every calendar is switched off
|
// The failure state's way out when every calendar is switched off
|
||||||
// (#239); nothing else in the calendar surfaces navigates here.
|
// (#239); nothing else in the calendar surfaces navigates here.
|
||||||
LocalManageCalendars provides { showCalendars = true },
|
LocalManageCalendars provides remember { { 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.
|
||||||
|
val requestVersion = ++navigationVersion
|
||||||
|
switchScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
if (navigationVersion == requestVersion) 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(viewStack.last().next(quickSwitchViews)) },
|
||||||
|
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||||
|
) { innerPadding ->
|
||||||
|
SharedTransitionLayout(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||||
|
) {
|
||||||
|
// 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
|
||||||
|
// among them must not replay this transition.
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view.screen,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
label = "view-switch",
|
label = "view-switch",
|
||||||
) { currentView ->
|
) { screen ->
|
||||||
when (currentView) {
|
val sharedScope = this@SharedTransitionLayout
|
||||||
CalendarView.Week -> WeekScreen(
|
val visibilityScope = this@AnimatedContent
|
||||||
selectedView = currentView,
|
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||||
onSelectView = onSelectView,
|
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalViewMorph provides viewMorph,
|
||||||
|
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||||
|
) {
|
||||||
|
when (screen) {
|
||||||
|
ViewScreen.Timeline -> TimelineScreen(
|
||||||
|
view = timelineView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
initialDateIso = pendingWeekIso,
|
|
||||||
onInitialDateConsumed = { pendingWeekIso = null },
|
|
||||||
)
|
)
|
||||||
CalendarView.MultiDay -> MultiDayScreen(
|
ViewScreen.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
|
||||||
CalendarView.Day -> DayScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
initialDateIso = pendingDayIso,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
|
||||||
CalendarView.Month -> MonthScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenWeek = onOpenWeek,
|
onOpenWeek = onOpenWeek,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
ViewScreen.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,11 +543,12 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSearch,
|
visible = showSearch,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onBack = { showSearch = false },
|
onBack = { showSearch = false },
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
visible = showSearch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +557,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = detailKey != null,
|
visible = detailKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
activeKey?.let { key ->
|
activeKey?.let { key ->
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
@@ -484,6 +577,7 @@ fun CalendarHost(
|
|||||||
importForm = form
|
importForm = form
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
visible = detailKey != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +586,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = createDateIso != null,
|
visible = createDateIso != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -501,6 +595,7 @@ fun CalendarHost(
|
|||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
visible = createDateIso != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,7 +604,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = editKey != null,
|
visible = editKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(editKey ?: heldEditKey)?.let { key ->
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -521,6 +616,7 @@ fun CalendarHost(
|
|||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
visible = editKey != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,12 +625,13 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,11 +683,12 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showCalendars,
|
visible = showCalendars,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(
|
CalendarsScreen(
|
||||||
onBack = { showCalendars = false },
|
onBack = { showCalendars = false },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +696,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackup,
|
visible = showBackup,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
// Settings can open this screen without the manager underneath, so
|
// Settings can open this screen without the manager underneath, so
|
||||||
// its failure states' "Manage calendars" way out has to open the
|
// its failure states' "Manage calendars" way out has to open the
|
||||||
@@ -625,6 +723,7 @@ fun CalendarHost(
|
|||||||
showBackup = false
|
showBackup = false
|
||||||
showCalendars = false
|
showCalendars = false
|
||||||
},
|
},
|
||||||
|
visible = showBackup,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,3 +735,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
|||||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun localToday(): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
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.EnterOnFocus
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -18,34 +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 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
|
||||||
@@ -58,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
|
||||||
@@ -81,26 +77,22 @@ 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(),
|
||||||
) {
|
) {
|
||||||
|
// Opens on the focused date and hands back the day scrolled to the top.
|
||||||
|
val focus = LocalViewFocus.current
|
||||||
|
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
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)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -124,112 +116,88 @@ fun AgendaScreen(
|
|||||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||||
// a row that reappears further down, and it follows it there.
|
// a row that reappears further down, and it follows it there.
|
||||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||||
val jumpToToday = {
|
val jumpToToday: () -> Unit = {
|
||||||
// Off today, moving the anchor is what resets the list (above); on today
|
// Off today, moving the anchor is what resets the list (above); on today
|
||||||
// the window doesn't change, so the scroll back is the whole action.
|
// the window doesn't change, so the scroll back is the whole action.
|
||||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
|
(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)
|
||||||
viewModel.goToDate(target)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
onSettings = {
|
|
||||||
onOpenSettings()
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
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) },
|
||||||
CalendarFabColumn(
|
),
|
||||||
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
|
)
|
||||||
!todayInToolbar,
|
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
ScreenBody(modifier = modifier) { innerPadding ->
|
||||||
onToday = jumpToToday,
|
Column(
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
modifier = Modifier
|
||||||
)
|
.fillMaxSize()
|
||||||
},
|
.padding(innerPadding),
|
||||||
) { innerPadding ->
|
) {
|
||||||
Column(
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
modifier = Modifier
|
// session range switcher on the right (one settings toggle).
|
||||||
.fillMaxSize()
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
.padding(innerPadding),
|
// end lines the selector up with whatever ends the top bar:
|
||||||
) {
|
// the view switcher's background, or — once #150 hides it —
|
||||||
// One bar at the top: the "showing …" header on the left and the
|
// the search icon's glyph.
|
||||||
// session range switcher on the right (one settings toggle).
|
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
AppBarSpacing.Inset
|
||||||
// end lines the selector up with whatever ends the top bar:
|
} else {
|
||||||
// the view switcher's background, or — once #150 hides it —
|
AppBarSpacing.IconTrailingInset
|
||||||
// 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(
|
Row(
|
||||||
state = state,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
pastDisplay = pastDisplay,
|
|
||||||
showToday = showToday,
|
|
||||||
listState = listState,
|
|
||||||
onRetry = viewModel::goToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
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 = if (successState?.anchor == anchor) {
|
||||||
|
{ focus?.date = it }
|
||||||
|
} else null,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +294,7 @@ internal fun AgendaContent(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -368,6 +337,7 @@ internal fun AgendaContent(
|
|||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onTopDay = onTopDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -386,8 +356,20 @@ private fun AgendaList(
|
|||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onTopDay: ((LocalDate) -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (onTopDay != null) {
|
||||||
|
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||||
|
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||||
|
// The day the list opened on is where the view already is; only a
|
||||||
|
// scroll moves it on.
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
snapshotFlow { listState.firstVisibleItemIndex }
|
||||||
|
.drop(1)
|
||||||
|
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -420,7 +402,7 @@ private fun AgendaList(
|
|||||||
zone = zone,
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -430,6 +412,13 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||||
|
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||||
|
days.flatMap { day ->
|
||||||
|
// Header, the placeholder or the events, then the gap.
|
||||||
|
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -451,58 +440,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.padding(start = AppBarSpacing.TitleInset),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onOpenDrawer) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.month_open_menu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TodayAction(show = showTodayButton, onToday = onToday)
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
|||||||
fun BackupScreen(
|
fun BackupScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||||
@@ -137,6 +138,7 @@ fun BackupScreen(
|
|||||||
scrollable = state is BackupUiState.Ready,
|
scrollable = state is BackupUiState.Ready,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
BackupUiState.Loading -> BackupLoading()
|
BackupUiState.Loading -> BackupLoading()
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
@@ -177,6 +178,7 @@ fun CalendarsScreen(
|
|||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
onOpenBackup = onOpenBackup,
|
onOpenBackup = onOpenBackup,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
visible = visible,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetVisible = viewModel::setCalendarVisible,
|
onSetVisible = viewModel::setCalendarVisible,
|
||||||
@@ -193,6 +195,7 @@ private fun CalendarsList(
|
|||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
@@ -218,6 +221,7 @@ private fun CalendarsList(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.ParentDataModifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** Gap a timed block leaves to its neighbours in the column. */
|
/** Gap a timed block leaves to its neighbours in the column. */
|
||||||
val BLOCK_OUTER_INSET = 1.dp
|
val BLOCK_OUTER_INSET = 1.dp
|
||||||
@@ -224,9 +229,13 @@ fun BlockTimeLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a timed block sits in its column, after tweening. */
|
/**
|
||||||
|
* Where a timed block sits in its column, after tweening: across as shares of
|
||||||
|
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||||
|
* down in the timeline's own units.
|
||||||
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||||
@@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||||
LocalTimelineZoom.current.isPinching
|
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
) {
|
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
snap()
|
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||||
} else {
|
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||||
|
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||||
|
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||||
|
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||||
|
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||||
|
|
||||||
|
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day column's blocks, each measured across the share of the column its
|
||||||
|
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||||
|
* at layout time, so the column can change width every frame without its
|
||||||
|
* blocks recomposing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||||
|
val width = constraints.maxWidth
|
||||||
|
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
|
val placed = measurables.map { measurable ->
|
||||||
|
val slot = measurable.parentData as? LaneSlot
|
||||||
|
if (slot == null) {
|
||||||
|
measurable.measure(loose) to 0
|
||||||
|
} else {
|
||||||
|
val left = (slot.x * width).roundToInt()
|
||||||
|
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||||
|
val laneWidth = (right - left).coerceAtLeast(0)
|
||||||
|
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout(width, constraints.maxHeight) {
|
||||||
|
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
|
||||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
|
||||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
|
||||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
|
||||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -10,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
|
||||||
@@ -33,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
|
||||||
@@ -42,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,
|
||||||
@@ -54,6 +63,7 @@ fun CalendarTitleButton(
|
|||||||
.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 }
|
||||||
@@ -71,21 +81,38 @@ fun CalendarTitleButton(
|
|||||||
availableWidth = constraints.maxWidth,
|
availableWidth = constraints.maxWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
// Cross-fades, so switching views or paging turns the title over
|
||||||
text = shown,
|
// rather than snapping it (#184).
|
||||||
style = style,
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
maxLines = 1,
|
AnimatedContent(
|
||||||
overflow = TextOverflow.Ellipsis,
|
targetState = shown,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = currentDate != null,
|
||||||
|
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
|
||||||
|
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
|||||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|
||||||
|
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
|
||||||
|
enum class ViewScreen { Month, Timeline, Agenda }
|
||||||
|
|
||||||
|
val CalendarView.screen: ViewScreen
|
||||||
|
get() = when (this) {
|
||||||
|
CalendarView.Month -> ViewScreen.Month
|
||||||
|
CalendarView.Agenda -> ViewScreen.Agenda
|
||||||
|
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
|
||||||
|
}
|
||||||
/** Push the week view over the month after a week-number tap. */
|
/** Push the week view over the month after a week-number tap. */
|
||||||
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||||
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
|
|||||||
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
||||||
* height; [color] is resolved by the caller from the theme.
|
* height; [color] is resolved by the caller from the theme.
|
||||||
*/
|
*/
|
||||||
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
fun Modifier.hourGridCells(
|
||||||
|
show: Boolean,
|
||||||
|
hourHeightPx: Float,
|
||||||
|
color: Color,
|
||||||
|
): Modifier =
|
||||||
if (!show) {
|
if (!show) {
|
||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
@@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
|
|||||||
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
||||||
)
|
)
|
||||||
val cellSize = Size(size.width, cellHeight)
|
val cellSize = Size(size.width, cellHeight)
|
||||||
|
val top = inset
|
||||||
for (hour in 0 until 24) {
|
for (hour in 0 until 24) {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = color,
|
color = color,
|
||||||
topLeft = Offset(0f, hour * hourHeightPx + inset),
|
topLeft = Offset(0f, hour * hourHeightPx + top),
|
||||||
size = cellSize,
|
size = cellSize,
|
||||||
cornerRadius = radius,
|
cornerRadius = radius,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where 00:00 sat in the last timeline shown, measured from the top of its
|
||||||
|
* view's content, so the next timeline view opens on the same hours (#184): an
|
||||||
|
* event in both keeps its place and only grows sideways.
|
||||||
|
*/
|
||||||
|
class TimelineScrollMemory {
|
||||||
|
var midnightY: Float? = 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, private val openingHour: Int?) {
|
||||||
|
/** 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) {
|
||||||
|
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
||||||
|
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||||
|
} else state.value.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
|
||||||
|
* its view's content top. Opens where the last timeline left off, or on noon the
|
||||||
|
* first time.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||||
|
val memory = LocalTimelineScroll.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val zoom = LocalTimelineZoom.current
|
||||||
|
val topPx = with(density) { timelineTop.toPx() }
|
||||||
|
val remembered = memory?.midnightY
|
||||||
|
val scrollState = rememberScrollState(
|
||||||
|
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||||
|
)
|
||||||
|
val scroll = remember(scrollState) {
|
||||||
|
TimelineScroll(
|
||||||
|
scrollState,
|
||||||
|
noonPending = remembered == null,
|
||||||
|
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val currentTop by rememberUpdatedState(topPx)
|
||||||
|
var firstOpen by remember(scrollState) { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(scrollState, zoom, zoom.hours) {
|
||||||
|
if (firstOpen && remembered != null) {
|
||||||
|
firstOpen = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
firstOpen = false
|
||||||
|
val startHour = zoom.open()
|
||||||
|
if (startHour == null) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
|
} else {
|
||||||
|
val (target, hourHeight) = snapshotFlow {
|
||||||
|
val viewport = scrollState.viewportSize
|
||||||
|
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||||
|
val hourPx = with(density) { hourHeight.toPx() }
|
||||||
|
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
||||||
|
}.filterNotNull().first()
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
zoom.holdFit(hourHeight)
|
||||||
|
}
|
||||||
|
scroll.noonPending = false
|
||||||
|
}
|
||||||
|
if (memory != null) {
|
||||||
|
LaunchedEffect(scrollState, memory) {
|
||||||
|
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
@@ -13,15 +13,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -285,31 +281,3 @@ internal fun anchoredScroll(
|
|||||||
* as a scroll stays one.
|
* as a scroll stays one.
|
||||||
*/
|
*/
|
||||||
private const val PINCH_SLOP = 0.08f
|
private const val PINCH_SLOP = 0.08f
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a timeline opens (#322): scrolled to the start of the visible hours when
|
|
||||||
* they are on, otherwise centred on noon. Re-runs when the visible-hours
|
|
||||||
* preference changes, so a range picked in Settings shows on the way back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) {
|
|
||||||
val density = LocalDensity.current
|
|
||||||
LaunchedEffect(zoom, zoom.hours) {
|
|
||||||
val startHour = zoom.open()
|
|
||||||
if (startHour == null) {
|
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
|
||||||
// Half the scroll range *is* noon: the content spans a full 24 hours,
|
|
||||||
// so centring the range centres midday at whatever hour height is in force.
|
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
|
||||||
} else {
|
|
||||||
val (target, hourHeight) = snapshotFlow {
|
|
||||||
val viewport = scrollState.viewportSize
|
|
||||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
|
||||||
val hourPx = with(density) { hourHeight.toPx() }
|
|
||||||
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
|
||||||
}.filterNotNull().first()
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
zoom.holdFit(hourHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
|||||||
val description = stringResource(R.string.today_jump_action)
|
val description = stringResource(R.string.today_jump_action)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onToday,
|
onClick = onToday,
|
||||||
modifier = Modifier.semantics { contentDescription = description },
|
modifier = Modifier
|
||||||
|
.semantics { contentDescription = description },
|
||||||
) {
|
) {
|
||||||
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date every calendar view is looking at (#184), so switching views keeps
|
||||||
|
* the same days on screen and what is shown in both can travel between them.
|
||||||
|
*
|
||||||
|
* A view reads it once, as it comes on screen, and writes it back only when the
|
||||||
|
* user moves: a view settling where it opened must not nudge it, or switching
|
||||||
|
* back and forth would drift.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ViewFocus(initial: LocalDate) {
|
||||||
|
var date by mutableStateOf(initial)
|
||||||
|
/** Explicit navigation, including a request for the view already on screen. */
|
||||||
|
var navigationVersion by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun navigateTo(target: LocalDate) {
|
||||||
|
date = target
|
||||||
|
navigationVersion++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
|
||||||
|
save = { it.date.toString() },
|
||||||
|
restore = { ViewFocus(LocalDate.parse(it)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [enter] with the focused date once, as the view comes on screen, before
|
||||||
|
* it reads its own position: its first frame is then already on that date.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
|
||||||
|
val focus = LocalViewFocus.current
|
||||||
|
remember(key) { focus?.date?.also(enter) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.BoundsTransform
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.mutableStateSetOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the views share when switching between them (#184): an event visible in
|
||||||
|
* both grows from where it sat into where it lands, a date label travels to its
|
||||||
|
* new seat, and everything else fades through.
|
||||||
|
*
|
||||||
|
* An element is tagged with the day it is drawn *from*, not just its instance: a
|
||||||
|
* multi-day event is drawn once per day, per week row or per timeline column,
|
||||||
|
* and only one element per key may take part. The segment that starts on the
|
||||||
|
* same day in both views is the one that travels; the rest fade where they are.
|
||||||
|
*/
|
||||||
|
sealed interface ViewMorphKey {
|
||||||
|
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
data class Day(val date: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** [event]'s occurrence as drawn from [day]. */
|
||||||
|
fun event(event: EventInstance, day: LocalDate): Event =
|
||||||
|
Event(event.eventId, event.start.toEpochMilliseconds(), day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
class ViewMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
val boundsTransform: BoundsTransform,
|
||||||
|
val fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
val clip: BelowTopBarClip,
|
||||||
|
val stayingPut: StayingPut,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events a view has but keeps out of the switch, being scrolled out of
|
||||||
|
* sight: the other view leaves them untagged too, or its half would travel to
|
||||||
|
* nowhere and snap into place at the end.
|
||||||
|
*/
|
||||||
|
class StayingPut {
|
||||||
|
val keys = mutableStateSetOf<ViewMorphKey.Event>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks [key] as staying put while [stays], for as long as it is composed. */
|
||||||
|
@Composable
|
||||||
|
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
|
||||||
|
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
|
||||||
|
if (!stays) return
|
||||||
|
DisposableEffect(stayingPut, key) {
|
||||||
|
stayingPut.keys += key
|
||||||
|
onDispose { stayingPut.keys -= key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps what travels below the top bar, which every view has at the same height:
|
||||||
|
* the overlay draws above the whole screen, so an event scrolled under the bar
|
||||||
|
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
|
||||||
|
var top = 0f
|
||||||
|
|
||||||
|
override fun getClipPath(
|
||||||
|
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||||
|
bounds: Rect,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FAR = 1e6f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pager page may carry the morph tags: only the settled one. Its
|
||||||
|
* neighbours are composed ahead and repeat some of its dates, which would both
|
||||||
|
* claim its keys and fly in from off screen.
|
||||||
|
*/
|
||||||
|
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
|
||||||
|
|
||||||
|
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
|
||||||
|
@Composable
|
||||||
|
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val morph = LocalViewMorph.current
|
||||||
|
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
|
||||||
|
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
|
||||||
|
* real size and is clipped while there is no room, instead of smearing.
|
||||||
|
*
|
||||||
|
* Goes after the element's offset and before its size, so the travelling bounds
|
||||||
|
* drive the measurement.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
if (key in morph.stayingPut.keys) return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphEvent.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
clipInOverlayDuringTransition = morph.clip,
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
zIndexInOverlay = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags a day's number, which is small on both sides, so it scales on its way. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphDay.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
clipInOverlayDuringTransition = morph.clip,
|
||||||
|
zIndexInOverlay = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberViewMorphBoundsTransform(): BoundsTransform {
|
||||||
|
val spec = rememberViewSwitchSpec<Rect>()
|
||||||
|
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
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.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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,11 +35,23 @@ fun ViewSwitcherPill(
|
|||||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||||
) {
|
) {
|
||||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier.padding(end = trailingInset),
|
modifier = modifier
|
||||||
|
.padding(end = trailingInset),
|
||||||
) {
|
) {
|
||||||
Text(current.label())
|
// The label cross-fades and the pill eases to its width, so cycling
|
||||||
|
// views turns it over rather than snapping it (#184).
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = current.label(),
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { label ->
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
|
||||||
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
|
||||||
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
|
||||||
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
|
||||||
*/
|
|
||||||
sealed interface DayUiState {
|
|
||||||
data object Loading : DayUiState
|
|
||||||
data class Failure(val reason: FailureReason) : DayUiState
|
|
||||||
data class Success(
|
|
||||||
val date: LocalDate,
|
|
||||||
val today: LocalDate,
|
|
||||||
/** All-day/multi-day events covering this day, stacked by lane. */
|
|
||||||
val allDay: List<AllDaySpan>,
|
|
||||||
/** Timed events clipped to this day with overlap lanes resolved. */
|
|
||||||
val timed: List<TimedBlock>,
|
|
||||||
) : DayUiState
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.filterNot
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.daysUntil
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
|
||||||
private const val DAY_CACHE_DAYS = 7
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class DayViewModel @Inject constructor(
|
|
||||||
private val repository: CalendarRepository,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
|
||||||
|
|
||||||
private val _date = MutableStateFlow(todayDate)
|
|
||||||
|
|
||||||
/** The pager page the anchor day sits on. */
|
|
||||||
val anchorPage: StateFlow<Int> = _date
|
|
||||||
.map { dayPageFor(it) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = dayPageFor(todayDate),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
|
||||||
* moving the anchor keeps the last result until the new day arrives, since
|
|
||||||
* the pages draw their own days and this only gates the failure screen.
|
|
||||||
*/
|
|
||||||
val state: StateFlow<DayUiState> = _date
|
|
||||||
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayUiState.Loading,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val days = PageStateCache<LocalDate, DayUiState>(
|
|
||||||
scope = viewModelScope,
|
|
||||||
initial = DayUiState.Loading,
|
|
||||||
// Far enough to cover the pages either side and a swipe back, so paging
|
|
||||||
// through a month doesn't keep a month of queries around.
|
|
||||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
|
||||||
) { date ->
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(dayRange(date, zone)),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(date, calendars, instances)
|
|
||||||
}
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.flowOn(io)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
|
||||||
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
|
||||||
|
|
||||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
|
||||||
fun onPageSettled(page: Int) {
|
|
||||||
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToToday() {
|
|
||||||
_date.value = todayDate
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
|
||||||
fun goToDate(date: LocalDate) {
|
|
||||||
_date.value = date
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildState(
|
|
||||||
day: LocalDate,
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
instances: List<EventInstance>,
|
|
||||||
): DayUiState {
|
|
||||||
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
|
|
||||||
val days = listOf(day)
|
|
||||||
val allDay = instances.filter { it.isAllDay }
|
|
||||||
val timed = instances.filterNot { it.isAllDay }
|
|
||||||
return DayUiState.Success(
|
|
||||||
date = day,
|
|
||||||
today = todayDate,
|
|
||||||
allDay = layoutAllDay(allDay, days, zone),
|
|
||||||
timed = layoutDay(timed, day, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Half-open instant range covering the single calendar [date]. */
|
|
||||||
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
|
||||||
val from = date.atStartOfDayIn(zone)
|
|
||||||
val to = date.atTime(23, 59, 59).toInstant(zone)
|
|
||||||
return from..to
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
|
||||||
* real; only the events are stand-ins.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun DayViewPreview(
|
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
|
||||||
// Half the scroll range is noon — the same centring the live view does.
|
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
|
||||||
DaySuccess(
|
|
||||||
state = state,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = state.allDayStripHeight(),
|
|
||||||
dragController = rememberTimelineDragController(),
|
|
||||||
onEventClick = {},
|
|
||||||
onCreateAt = { _, _ -> },
|
|
||||||
onDrop = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
|
||||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
|
||||||
val days = listOf(today)
|
|
||||||
val events = sampleTimelineEvents(days, today, zone)
|
|
||||||
return DayUiState.Success(
|
|
||||||
date = today,
|
|
||||||
today = today,
|
|
||||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
|
||||||
timed = layoutDay(events, today, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -140,6 +140,7 @@ fun EventDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDuplicate: (EventForm) -> Unit,
|
onDuplicate: (EventForm) -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -227,7 +228,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ fun EventEditScreen(
|
|||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -285,7 +286,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.predictiveBack(onBack = close),
|
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ fun ImportScreen(
|
|||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// hiltViewModel() resolves to the Activity's store and is retained across
|
// hiltViewModel() resolves to the Activity's store and is retained across
|
||||||
@@ -103,7 +104,7 @@ fun ImportScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.predictiveBack(onBack = onClose)
|
.predictiveBack(onBack = onClose, visible = visible)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
* what the *recipient of the binary* is told. Each row opens the project's source.
|
* what the *recipient of the binary* is told. Each row opens the project's source.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LicencesScreen(onBack: () -> Unit) {
|
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.licences_title),
|
title = stringResource(R.string.licences_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.licences_intro),
|
stringResource(R.string.licences_intro),
|
||||||
|
|||||||
@@ -9,8 +9,20 @@ 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.EnterOnFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.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
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
@@ -33,7 +45,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
|
||||||
@@ -62,23 +73,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
|||||||
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.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.InputChip
|
import androidx.compose.material3.InputChip
|
||||||
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,7 +100,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
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.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
@@ -126,7 +131,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
|
||||||
@@ -161,11 +165,7 @@ 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.agenda.agendaDayLabel
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
|
||||||
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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -179,7 +179,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
|
||||||
@@ -250,20 +249,16 @@ private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): M
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MonthScreen(
|
fun MonthScreen(
|
||||||
selectedView: CalendarView,
|
|
||||||
onSelectView: (CalendarView) -> Unit,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenWeek: (LocalDate) -> Unit,
|
onOpenWeek: (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(),
|
||||||
) {
|
) {
|
||||||
|
// Opens on the focused date's month, and hands its selection back as it moves.
|
||||||
|
val focus = LocalViewFocus.current
|
||||||
|
EnterOnFocus(viewModel) { viewModel.goToDate(it) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
|
val continuousState by viewModel.continuousState.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
@@ -280,15 +275,16 @@ fun MonthScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val scrolling = viewStyle.isScrolling
|
val scrolling = viewStyle.isScrolling
|
||||||
|
var pendingScrollMonth by remember { mutableStateOf<YearMonth?>(null) }
|
||||||
|
var scrollRequestId by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// The paged and split styles' months. Made afresh whenever a paged style
|
// The paged and split styles' months. Made afresh whenever a paged style
|
||||||
// comes on screen, opening on the month the scrolling style was showing: a
|
// comes on screen, opening on the month the scrolling style was showing: a
|
||||||
// pager that was never laid out can't be scrolled to follow it meanwhile.
|
// pager that was never laid out can't be scrolled to follow it meanwhile.
|
||||||
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
val anchorPage = monthIndexOf(month)
|
||||||
val pagerState = key(scrolling) {
|
val pagerState = key(scrolling) {
|
||||||
rememberPagerState(initialPage = anchorPage) { continuousMonthCount() }
|
rememberPagerState(initialPage = anchorPage) { continuousMonthCount() }
|
||||||
}
|
}
|
||||||
@@ -305,6 +301,15 @@ fun MonthScreen(
|
|||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||||
}
|
}
|
||||||
|
// Keep the shared focus on the visible month or its selected day.
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
snapshotFlow { month to selectedDate }.drop(1).collect { (visible, selected) ->
|
||||||
|
val current = focus?.date
|
||||||
|
focus?.date = selected ?: current?.takeIf {
|
||||||
|
it.year == visible.year && it.month == visible.month
|
||||||
|
} ?: LocalDate(visible.year, visible.month, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
val dense = viewStyle == MonthViewStyle.Dense
|
val dense = viewStyle == MonthViewStyle.Dense
|
||||||
|
|
||||||
// Today, from whichever state is driving; the clock only covers the first
|
// Today, from whichever state is driving; the clock only covers the first
|
||||||
@@ -376,15 +381,21 @@ fun MonthScreen(
|
|||||||
// back there rather than staying on the month you were looking at.
|
// back there rather than staying on the month you were looking at.
|
||||||
LaunchedEffect(listState, scrolling, dense, weekStart) {
|
LaunchedEffect(listState, scrolling, dense, weekStart) {
|
||||||
if (!scrolling) return@LaunchedEffect
|
if (!scrolling) return@LaunchedEffect
|
||||||
snapshotFlow { visibleMonth }
|
snapshotFlow { visibleMonth to pendingScrollMonth }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { viewModel.syncScrollMonth(it) }
|
.collect { (visible, pending) ->
|
||||||
|
if (pending == null || pending == visible) {
|
||||||
|
viewModel.syncScrollMonth(visible)
|
||||||
|
if (pending == visible) pendingScrollMonth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||||
var splitExpanded by rememberSaveable(viewStyle) {
|
var splitExpanded by rememberSaveable(viewStyle) {
|
||||||
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
||||||
}
|
}
|
||||||
|
var todayPulse by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
// Continuous names the month on the block's own sticky header, so the bar
|
// Continuous names the month on the block's own sticky header, so the bar
|
||||||
// carries the year instead of repeating it two lines further down. Dense has
|
// carries the year instead of repeating it two lines further down. Dense has
|
||||||
@@ -406,15 +417,24 @@ fun MonthScreen(
|
|||||||
val jumpToMonth: (YearMonth) -> Unit = { ym ->
|
val jumpToMonth: (YearMonth) -> Unit = { ym ->
|
||||||
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
|
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
|
||||||
}
|
}
|
||||||
var todayPulse by remember { mutableIntStateOf(0) }
|
|
||||||
val jumpToToday: () -> Unit = {
|
val jumpToToday: () -> Unit = {
|
||||||
todayPulse++
|
todayPulse++
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
|
val targetMonth = YearMonth(today.year, today.month)
|
||||||
|
pendingScrollMonth = targetMonth
|
||||||
|
val requestId = ++scrollRequestId
|
||||||
|
viewModel.goToToday()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState.animateScrollToItem(
|
try {
|
||||||
if (dense) weekIndexOf(today, weekStart)
|
listState.animateScrollToItem(
|
||||||
else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))),
|
if (dense) weekIndexOf(today, weekStart)
|
||||||
)
|
else itemIndexForMonth(monthIndexOf(targetMonth)),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (scrollRequestId == requestId) {
|
||||||
|
pendingScrollMonth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.goToToday()
|
viewModel.goToToday()
|
||||||
@@ -424,11 +444,21 @@ fun MonthScreen(
|
|||||||
// Drawer jump-to-date.
|
// Drawer jump-to-date.
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
|
val targetMonth = YearMonth(target.year, target.month)
|
||||||
|
pendingScrollMonth = targetMonth
|
||||||
|
val requestId = ++scrollRequestId
|
||||||
|
viewModel.goToDate(target)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
listState.animateScrollToItem(
|
try {
|
||||||
if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart)
|
listState.animateScrollToItem(
|
||||||
else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))),
|
if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart)
|
||||||
)
|
else itemIndexForMonth(monthIndexOf(targetMonth)),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (scrollRequestId == requestId) {
|
||||||
|
pendingScrollMonth = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
viewModel.goToDate(target)
|
viewModel.goToDate(target)
|
||||||
@@ -436,141 +466,106 @@ 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 ||
|
||||||
onSettings = {
|
(viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate
|
||||||
onOpenSettings()
|
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||||
scope.launch { drawerState.close() }
|
isOnCurrentMonth -> today
|
||||||
},
|
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
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 = {
|
|
||||||
CalendarFabColumn(
|
|
||||||
todayVisible = !isOnCurrentMonth && !todayInToolbar,
|
|
||||||
todayText = stringResource(R.string.month_today_action),
|
|
||||||
onToday = jumpToToday,
|
|
||||||
onCreate = {
|
|
||||||
// Split has a selected day; the other styles anchor on
|
|
||||||
// today when its month is shown, else the 1st.
|
|
||||||
onCreateEvent(
|
|
||||||
when {
|
|
||||||
(viewStyle == MonthViewStyle.Split ||
|
|
||||||
(viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate
|
|
||||||
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
|
|
||||||
isOnCurrentMonth -> today
|
|
||||||
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
),
|
||||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
)
|
||||||
// overlay, clear of the week row's and the list viewport's clips.
|
|
||||||
val chipDrag = rememberMonthDragController()
|
ScreenBody(modifier = modifier) { innerPadding ->
|
||||||
// Undo moves the event back, so the chip travels back too. The
|
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||||
// signal comes from the write, not from the chip that offers it.
|
// overlay, clear of the week row's and the list viewport's clips.
|
||||||
OnUndoStarted { chipDrag.beginUndo() }
|
val chipDrag = rememberMonthDragController()
|
||||||
Box(
|
// Undo moves the event back, so the chip travels back too. The
|
||||||
modifier = Modifier
|
// signal comes from the write, not from the chip that offers it.
|
||||||
.padding(innerPadding)
|
OnUndoStarted { chipDrag.beginUndo() }
|
||||||
.fillMaxSize(),
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalMonthDrag provides chipDrag,
|
||||||
|
LocalTodayPulse provides todayPulse,
|
||||||
|
LocalOpenWeek provides onOpenWeek,
|
||||||
) {
|
) {
|
||||||
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,
|
||||||
LocalTodayPulse provides todayPulse,
|
showWeekNumbers = showWeekNumbers,
|
||||||
LocalOpenWeek provides onOpenWeek,
|
onRetry = jumpToToday,
|
||||||
) {
|
onOpenDay = onOpenDay,
|
||||||
if (scrolling) {
|
onEventClick = onEventClick,
|
||||||
ContinuousMonthContent(
|
)
|
||||||
state = continuousState,
|
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
|
||||||
listState = listState,
|
SplitMonthContent(
|
||||||
dense = dense,
|
state = state,
|
||||||
showWeekNumbers = showWeekNumbers,
|
selected = selectedDate,
|
||||||
onRetry = jumpToToday,
|
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
||||||
onOpenDay = onOpenDay,
|
expanded = splitExpanded,
|
||||||
onEventClick = onEventClick,
|
onExpandedChange = { splitExpanded = it },
|
||||||
)
|
pagerState = pagerState,
|
||||||
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
|
weekStart = weekStart,
|
||||||
SplitMonthContent(
|
monthState = viewModel::month,
|
||||||
state = state,
|
showWeekNumbers = showWeekNumbers,
|
||||||
selected = selectedDate,
|
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||||
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||||
expanded = splitExpanded,
|
onClearSelection = viewModel::clearSelection,
|
||||||
onExpandedChange = { splitExpanded = it },
|
onRetry = jumpToToday,
|
||||||
pagerState = pagerState,
|
// A tapped leading/trailing day follows to its own
|
||||||
weekStart = weekStart,
|
// month, pager and all — straight away, or a swipe
|
||||||
monthState = viewModel::month,
|
// still settling would land and take the tap back.
|
||||||
showWeekNumbers = showWeekNumbers,
|
onSelectDay = { date ->
|
||||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
viewModel.selectDate(date)
|
||||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
jumpToMonth(YearMonth(date.year, date.month))
|
||||||
onClearSelection = viewModel::clearSelection,
|
},
|
||||||
onRetry = jumpToToday,
|
onOpenDay = onOpenDay,
|
||||||
// A tapped leading/trailing day follows to its own
|
onEventClick = onEventClick,
|
||||||
// month, pager and all — straight away, or a swipe
|
onCreateEvent = { onCreateEvent(it, null) },
|
||||||
// still settling would land and take the tap back.
|
)
|
||||||
onSelectDay = { date ->
|
} else {
|
||||||
viewModel.selectDate(date)
|
MonthContent(
|
||||||
jumpToMonth(YearMonth(date.year, date.month))
|
state = state,
|
||||||
},
|
pagerState = pagerState,
|
||||||
onOpenDay = onOpenDay,
|
weekStart = weekStart,
|
||||||
onEventClick = onEventClick,
|
monthState = viewModel::month,
|
||||||
onCreateEvent = { onCreateEvent(it, null) },
|
showWeekNumbers = showWeekNumbers,
|
||||||
)
|
onRetry = jumpToToday,
|
||||||
} else {
|
onOpenDay = onOpenDay,
|
||||||
MonthContent(
|
onEventClick = onEventClick,
|
||||||
state = state,
|
)
|
||||||
pagerState = pagerState,
|
|
||||||
weekStart = weekStart,
|
|
||||||
monthState = viewModel::month,
|
|
||||||
showWeekNumbers = showWeekNumbers,
|
|
||||||
onRetry = jumpToToday,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MonthDragOverlay(chipDrag)
|
}
|
||||||
}
|
MonthDragOverlay(chipDrag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -739,7 +734,7 @@ private fun MonthPager(
|
|||||||
) {
|
) {
|
||||||
if (static) {
|
if (static) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page)
|
MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -758,7 +753,15 @@ private fun MonthPager(
|
|||||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||||
),
|
),
|
||||||
) { index ->
|
) { index ->
|
||||||
MonthPage(index, index == pagerState.currentPage, weekStart, monthState, loading, page)
|
MonthPage(
|
||||||
|
index = index,
|
||||||
|
active = index == pagerState.currentPage,
|
||||||
|
morphActive = morphPageActive(index, pagerState.settledPage),
|
||||||
|
weekStart = weekStart,
|
||||||
|
monthState = monthState,
|
||||||
|
loading = loading,
|
||||||
|
page = page,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,6 +769,7 @@ private fun MonthPager(
|
|||||||
private fun MonthPage(
|
private fun MonthPage(
|
||||||
index: Int,
|
index: Int,
|
||||||
active: Boolean,
|
active: Boolean,
|
||||||
|
morphActive: Boolean,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||||
loading: @Composable () -> Unit,
|
loading: @Composable () -> Unit,
|
||||||
@@ -775,9 +779,11 @@ private fun MonthPage(
|
|||||||
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
||||||
val chipDrag = LocalMonthDrag.current
|
val chipDrag = LocalMonthDrag.current
|
||||||
val morph = LocalMonthMorph.current
|
val morph = LocalMonthMorph.current
|
||||||
|
val viewMorph = LocalViewMorph.current
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalMonthDrag provides chipDrag.takeIf { active },
|
LocalMonthDrag provides chipDrag.takeIf { active },
|
||||||
LocalMonthMorph provides morph.takeIf { active },
|
LocalMonthMorph provides morph.takeIf { active },
|
||||||
|
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is MonthUiState.Success -> page(index, s)
|
is MonthUiState.Success -> page(index, s)
|
||||||
@@ -828,62 +834,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) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.month_open_menu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TodayAction(show = showTodayButton, onToday = onToday)
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -1418,6 +1368,10 @@ private fun SplitMonthContent(
|
|||||||
// its month still for the length of the cross-fade.
|
// its month still for the length of the cross-fade.
|
||||||
val leaving = transition.targetState == EnterExitState.PostExit
|
val leaving = transition.targetState == EnterExitState.PostExit
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
|
// The split style morphs within itself and so sits the view
|
||||||
|
// switch's morph out (#184): one element can't travel in two
|
||||||
|
// shared transitions at once.
|
||||||
|
LocalViewMorph provides null,
|
||||||
// Null under reduced motion: nothing is tagged, nothing
|
// Null under reduced motion: nothing is tagged, nothing
|
||||||
// travels, and the cross-fade above is the whole transition.
|
// travels, and the cross-fade above is the whole transition.
|
||||||
LocalMonthMorph provides if (reduceMotion) {
|
LocalMonthMorph provides if (reduceMotion) {
|
||||||
@@ -2452,6 +2406,17 @@ private fun MonthWeekRow(
|
|||||||
instanceId = span.event.instanceId,
|
instanceId = span.event.instanceId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A blanked day is drawn again in its own
|
||||||
|
// month's block, which owns the tag.
|
||||||
|
.then(
|
||||||
|
week.days[span.startCol].let { start ->
|
||||||
|
if (blankOutside && !inMonth(start)) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.morphEvent(ViewMorphKey.event(span.event, start))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
.width(colW * cols)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||||
@@ -2509,6 +2474,13 @@ private fun MonthWeekRow(
|
|||||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||||
)
|
)
|
||||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||||
|
.then(
|
||||||
|
if (blankOutside && !inMonth(d)) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.morphEvent(ViewMorphKey.event(ev, d))
|
||||||
|
},
|
||||||
|
)
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||||
@@ -2780,6 +2752,7 @@ private fun DayNumberCell(
|
|||||||
if (isToday) {
|
if (isToday) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.morphDay(date)
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -2800,6 +2773,7 @@ private fun DayNumberCell(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Visible,
|
overflow = TextOverflow.Visible,
|
||||||
softWrap = false,
|
softWrap = false,
|
||||||
|
modifier = Modifier.morphDay(date),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
@@ -2807,6 +2781,7 @@ private fun DayNumberCell(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
color = if (inMonth) MaterialTheme.colorScheme.onSurface
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||||
|
modifier = Modifier.morphDay(date),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.label
|
import de.jeanlucmakiola.calendula.ui.common.label
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
@@ -73,7 +72,7 @@ internal fun ViewStep(
|
|||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
dayCount = LocalMultiDayCount.current,
|
dayCount = LocalMultiDayCount.current,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
|
||||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ fun SearchScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: SearchViewModel = hiltViewModel(),
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
@@ -184,7 +185,7 @@ fun SearchScreen(
|
|||||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
SearchTopBar(
|
SearchTopBar(
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
|
|||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// Theme & colour
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||||
// The calendar whose duration-override picker is open, if any.
|
// The calendar whose duration-override picker is open, if any.
|
||||||
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
|
|||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSpecialDates: () -> Unit,
|
onOpenSpecialDates: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
|
|||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -85,11 +84,14 @@ fun SettingsScreen(
|
|||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -101,61 +103,63 @@ fun SettingsScreen(
|
|||||||
onOpenSection = { section = it },
|
onOpenSection = { section = it },
|
||||||
onManageCalendars = onManageCalendars,
|
onManageCalendars = onManageCalendars,
|
||||||
onOpenBackup = onOpenBackup,
|
onOpenBackup = onOpenBackup,
|
||||||
|
visible = visible,
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Appearance,
|
visible = section == SettingsSection.Appearance,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Views,
|
visible = section == SettingsSection.Views,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.EventForm,
|
visible = section == SettingsSection.EventForm,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Notifications,
|
visible = section == SettingsSection.Notifications,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
NotificationsScreen(
|
NotificationsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBack = { section = null },
|
onBack = { section = null },
|
||||||
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||||
|
visible = section == SettingsSection.Notifications,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.SpecialDates,
|
visible = section == SettingsSection.SpecialDates,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Widgets,
|
visible = section == SettingsSection.Widgets,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Licences,
|
visible = section == SettingsSection.Licences,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
LicencesScreen(onBack = { section = null })
|
LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,8 +174,14 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_title),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
|
) {
|
||||||
// Card and support row are one grouped block, so the call to action
|
// Card and support row are one grouped block, so the call to action
|
||||||
// continues the container instead of sitting inside it as a button.
|
// continues the container instead of sitting inside it as a button.
|
||||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|||||||
internal fun SpecialDatesScreen(
|
internal fun SpecialDatesScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
|
|||||||
title = stringResource(R.string.settings_section_special_dates),
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// Paused banner: the permission was revoked after enabling.
|
// Paused banner: the permission was revoked after enabling.
|
||||||
if (state.enabled && state.stalledPermission) {
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ internal fun ViewsScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showMonthStyle by remember { mutableStateOf(false) }
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
@@ -85,6 +86,7 @@ internal fun ViewsScreen(
|
|||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
var showWidgetSize by remember { mutableStateOf(false) }
|
var showWidgetSize by remember { mutableStateOf(false) }
|
||||||
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
|
|||||||
title = stringResource(R.string.settings_section_widgets),
|
title = stringResource(R.string.settings_section_widgets),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** A day column's place across the timeline, in pixels from its start edge. */
|
||||||
|
@Immutable
|
||||||
|
data class ColumnSlot(val left: Float, val width: Float) {
|
||||||
|
val right: Float get() = left + width
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [date]'s column on a page showing this span across [width] pixels, with [gap]
|
||||||
|
* between columns. A date off the page carries on at the same pitch, off screen
|
||||||
|
* on its side — where a view switch slides it in from, or out to.
|
||||||
|
*/
|
||||||
|
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||||
|
val pitch = (width + gap) / count
|
||||||
|
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last day of the span. */
|
||||||
|
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's columns: one page's, or — while switching between the day, the
|
||||||
|
* multi-day and the week view (#184) — every day of both pages, each column
|
||||||
|
* between where it sits in the one and where it sits in the other.
|
||||||
|
*
|
||||||
|
* @param progress how far from [from] to [to], read at layout time only.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
|
||||||
|
|
||||||
|
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
|
||||||
|
val dates: List<LocalDate> = run {
|
||||||
|
val first = minOf(from.start, to.start)
|
||||||
|
val last = maxOf(from.end, to.end)
|
||||||
|
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||||
|
val a = from.slot(date, width, gap)
|
||||||
|
if (from == to) return a
|
||||||
|
val b = to.slot(date, width, gap)
|
||||||
|
val p = progress()
|
||||||
|
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The span of columns [range] covers, between its extent on each page. */
|
||||||
|
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||||
|
val a = from.extent(fromRange, width, gap)
|
||||||
|
if (from == to && fromRange == toRange) return a
|
||||||
|
val b = to.extent(toRange, width, gap)
|
||||||
|
val p = progress()
|
||||||
|
val left = lerp(a.left, b.left, p)
|
||||||
|
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** One page's columns, standing still. */
|
||||||
|
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||||
|
val left = slot(range.start, width, gap).left
|
||||||
|
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays [content] out once per day of [geometry], each across its column. The
|
||||||
|
* columns are placed in the layout pass, so a view switch moving them every
|
||||||
|
* frame re-lays them out without recomposing them. Children fill the height
|
||||||
|
* the layout is given, or wrap it when it has none.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TimelineColumns(
|
||||||
|
geometry: ColumnGeometry,
|
||||||
|
gap: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
val dates = geometry.dates
|
||||||
|
Layout(
|
||||||
|
content = {
|
||||||
|
dates.forEach { date ->
|
||||||
|
key(date) { Box(propagateMinConstraints = true) { content(date) } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
) { measurables, constraints ->
|
||||||
|
val width = constraints.maxWidth
|
||||||
|
val gapPx = gap.toPx()
|
||||||
|
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
|
||||||
|
val placeables = measurables.mapIndexed { i, measurable ->
|
||||||
|
val left = slots[i].left.roundToInt()
|
||||||
|
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
|
||||||
|
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
|
||||||
|
}
|
||||||
|
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
|
||||||
|
?: constraints.minHeight
|
||||||
|
layout(width, height) {
|
||||||
|
placeables.forEachIndexed { i, placeable ->
|
||||||
|
placeable.placeRelative(slots[i].left.roundToInt(), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An all-day bar as each page lays it out: the days it covers there, clipped to
|
||||||
|
* the page, and its lane. A bar only one page has keeps its days on the other,
|
||||||
|
* so it slides out with them rather than vanishing.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class PlacedBar(
|
||||||
|
val event: EventInstance,
|
||||||
|
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
|
||||||
|
val keyDate: LocalDate,
|
||||||
|
val fromRange: ClosedRange<LocalDate>,
|
||||||
|
val toRange: ClosedRange<LocalDate>,
|
||||||
|
val fromLane: Int,
|
||||||
|
val toLane: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [state]'s all-day bars, standing still. */
|
||||||
|
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
|
||||||
|
state.allDaySpans.map { span ->
|
||||||
|
val range = state.days[span.startCol]..state.days[span.endCol]
|
||||||
|
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day bars of both pages of a view switch, each once: matched by
|
||||||
|
* occurrence, placed on each page as that page lays it out.
|
||||||
|
*/
|
||||||
|
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
|
||||||
|
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||||
|
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||||
|
return (fromBars.keys + toBars.keys).map { occurrence ->
|
||||||
|
val a = fromBars[occurrence]
|
||||||
|
val b = toBars[occurrence]
|
||||||
|
PlacedBar(
|
||||||
|
event = (a ?: b)!!.event,
|
||||||
|
keyDate = (a ?: b)!!.keyDate,
|
||||||
|
fromRange = (a ?: b)!!.fromRange,
|
||||||
|
toRange = (b ?: a)!!.toRange,
|
||||||
|
fromLane = (a ?: b)!!.fromLane,
|
||||||
|
toLane = (b ?: a)!!.toLane,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A switch between two timeline views in progress (#184): the page on screen
|
||||||
|
* when it started, the page it ends on, and how far between the two it is.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
internal class TimelineMorph(
|
||||||
|
val fromKind: TimelineKind,
|
||||||
|
val toKind: TimelineKind,
|
||||||
|
val from: DaySpan,
|
||||||
|
val to: DaySpan,
|
||||||
|
val fromState: StateFlow<WeekUiState>,
|
||||||
|
val toState: StateFlow<WeekUiState>,
|
||||||
|
) {
|
||||||
|
val progress = Animatable(0f)
|
||||||
|
|
||||||
|
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
|
||||||
|
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
|
||||||
|
|
||||||
|
/** Where each day's events come from: the page it was on, else the page it goes to. */
|
||||||
|
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
|
||||||
|
|
||||||
|
enum class Side { From, To }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LONG_SWITCH_RATIO = 3
|
||||||
@@ -73,3 +73,35 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
|||||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
|
||||||
|
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||||
|
|
||||||
|
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||||
|
|
||||||
|
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||||
|
private val DAY_EPOCH = LocalDate(2000, 1, 1)
|
||||||
|
|
||||||
|
/** Single days: every date starts a page of its own. */
|
||||||
|
data object DayGrid : PageGrid {
|
||||||
|
override val dayCount: Int get() = 1
|
||||||
|
override val pageCount: Int get() = DAY_PAGE_COUNT
|
||||||
|
|
||||||
|
override fun startForPage(page: Int): LocalDate =
|
||||||
|
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
override fun pageFor(date: LocalDate): Int =
|
||||||
|
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the focused date goes when the pager settles on [page] (#184): kept if
|
||||||
|
* the page still shows it, otherwise moved by the pages travelled, so it keeps
|
||||||
|
* its place in the page — the same weekday a week on.
|
||||||
|
*/
|
||||||
|
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||||
|
val start = startForPage(page)
|
||||||
|
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
|
if (focus in start..end) return focus
|
||||||
|
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNot
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -55,6 +56,10 @@ data class DaySpan(val start: LocalDate, val count: Int)
|
|||||||
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
||||||
data class PagerPosition(val grid: PageGrid, val page: Int)
|
data class PagerPosition(val grid: PageGrid, val page: Int)
|
||||||
|
|
||||||
|
/** Where [anchor] sits under [rule]. */
|
||||||
|
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
|
||||||
|
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
|
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
|
||||||
* The pager leads; the anchor follows it once it settles, and the grid it is
|
* The pager leads; the anchor follows it once it settles, and the grid it is
|
||||||
@@ -89,14 +94,22 @@ abstract class PagedTimelineViewModel(
|
|||||||
// A day on the visible page; the page itself comes from the grid, so a
|
// A day on the visible page; the page itself comes from the grid, so a
|
||||||
// settings change re-frames the same days instead of jumping.
|
// settings change re-frames the same days instead of jumping.
|
||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
val position: StateFlow<PagerPosition> =
|
/**
|
||||||
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
|
* The grid for a given anchor, held eagerly so a screen can place its pager
|
||||||
|
* on its very first frame rather than a frame after the anchor moved.
|
||||||
|
*/
|
||||||
|
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
||||||
|
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
||||||
|
|
||||||
|
private val position: StateFlow<PagerPosition> =
|
||||||
|
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) },
|
initialValue = positionOf(todayDate, initialRule),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +153,7 @@ abstract class PagedTimelineViewModel(
|
|||||||
|
|
||||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
fun onPageSettled(page: Int) {
|
fun onPageSettled(page: Int) {
|
||||||
val grid = position.value.grid
|
val grid = rule.value(_anchor.value)
|
||||||
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
|
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +222,20 @@ class MultiDayViewModel @Inject constructor(
|
|||||||
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
|
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The day view: the timeline one day at a time. */
|
||||||
|
@HiltViewModel
|
||||||
|
class DayViewModel @Inject constructor(
|
||||||
|
repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
|
) : PagedTimelineViewModel(
|
||||||
|
repository = repository,
|
||||||
|
settingsPrefs = settingsPrefs,
|
||||||
|
io = io,
|
||||||
|
gridRule = flowOf { _: LocalDate -> DayGrid },
|
||||||
|
initialRule = { DayGrid },
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ internal fun WeekViewPreview(
|
|||||||
WeekSuccess(
|
WeekSuccess(
|
||||||
state = state,
|
state = state,
|
||||||
showWeekNumber = dayCount == 7,
|
showWeekNumber = dayCount == 7,
|
||||||
|
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** The day each agenda list item belongs to, for the focus it hands back (#184). */
|
||||||
|
class AgendaItemDatesTest {
|
||||||
|
|
||||||
|
private val monday = LocalDate(2026, 9, 28)
|
||||||
|
private val tuesday = LocalDate(2026, 9, 29)
|
||||||
|
|
||||||
|
private fun event(id: Long) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Event",
|
||||||
|
start = Instant.fromEpochMilliseconds(0),
|
||||||
|
end = Instant.fromEpochMilliseconds(0),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF000000.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each day counts its header, events and gap`() {
|
||||||
|
val days = listOf(AgendaDay(monday, listOf(event(1), event(2))), AgendaDay(tuesday, listOf(event(3))))
|
||||||
|
assertThat(agendaItemDates(days)).containsExactly(
|
||||||
|
monday, monday, monday, monday,
|
||||||
|
tuesday, tuesday, tuesday,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty day counts its placeholder`() {
|
||||||
|
assertThat(agendaItemDates(listOf(AgendaDay(monday, emptyList())))).hasSize(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ViewMorphKeyTest {
|
||||||
|
|
||||||
|
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
|
||||||
|
instanceId = instanceId,
|
||||||
|
eventId = eventId,
|
||||||
|
calendarId = 1,
|
||||||
|
title = "Standup",
|
||||||
|
start = Instant.fromEpochMilliseconds(startMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val day = LocalDate(2026, 9, 25)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same occurrence on the same day matches across views`() {
|
||||||
|
// Each view reads its own instances query, so the row id is no anchor.
|
||||||
|
assertThat(ViewMorphKey.event(instance(instanceId = 1), day))
|
||||||
|
.isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day event keys each day it is drawn from apart`() {
|
||||||
|
assertThat(ViewMorphKey.event(instance(1), day))
|
||||||
|
.isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two occurrences of one series stay apart`() {
|
||||||
|
assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day))
|
||||||
|
.isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the settled page morphs`() {
|
||||||
|
assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue()
|
||||||
|
assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse()
|
||||||
|
assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Which screen draws each view: the timeline views share one, so switching among them resizes it (#184). */
|
||||||
|
class ViewScreenTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the day, multi-day and week views share the timeline`() {
|
||||||
|
assertThat(listOf(CalendarView.Day, CalendarView.MultiDay, CalendarView.Week).map { it.screen }.toSet())
|
||||||
|
.containsExactly(ViewScreen.Timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `month and agenda each have their own screen`() {
|
||||||
|
assertThat(CalendarView.Month.screen).isEqualTo(ViewScreen.Month)
|
||||||
|
assertThat(CalendarView.Agenda.screen).isEqualTo(ViewScreen.Agenda)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** The timeline's columns, standing still and mid view switch (#184). */
|
||||||
|
class ColumnGeometryTest {
|
||||||
|
|
||||||
|
// 2026-09-27 is a Sunday; the week runs Sun..Sat.
|
||||||
|
private val sunday = LocalDate(2026, 9, 27)
|
||||||
|
private val week = DaySpan(sunday, 7)
|
||||||
|
private val monday = sunday.plus(1, DateTimeUnit.DAY)
|
||||||
|
private val day = DaySpan(monday, 1)
|
||||||
|
private val width = 700f
|
||||||
|
private val gap = 7f
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a page's columns follow the pitch`() {
|
||||||
|
val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap)
|
||||||
|
assertThat(slot.left).isEqualTo(3 * 101f)
|
||||||
|
assertThat(slot.width).isEqualTo(94f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the ends of a switch are the two pages`() {
|
||||||
|
var p = 0f
|
||||||
|
val geometry = ColumnGeometry(week, day) { p }
|
||||||
|
assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap))
|
||||||
|
p = 1f
|
||||||
|
assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days off the target page end off screen on their side`() {
|
||||||
|
val geometry = ColumnGeometry(week, day) { 1f }
|
||||||
|
assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f)
|
||||||
|
assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a switch covers both pages without a break`() {
|
||||||
|
// Focus on Saturday: the six-day run starting there reaches into next week.
|
||||||
|
val saturday = sunday.plus(6, DateTimeUnit.DAY)
|
||||||
|
val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f }
|
||||||
|
assertThat(geometry.dates).hasSize(12)
|
||||||
|
assertThat(geometry.dates.first()).isEqualTo(sunday)
|
||||||
|
assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar spans its extent on each page`() {
|
||||||
|
val tuesday = monday.plus(1, DateTimeUnit.DAY)
|
||||||
|
val range = monday..tuesday
|
||||||
|
val geometry = ColumnGeometry(week, day) { 0f }
|
||||||
|
val extent = geometry.extent(range, monday..monday, width, 0f)
|
||||||
|
assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left)
|
||||||
|
assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar only one page has keeps its days on the other`() {
|
||||||
|
val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1)
|
||||||
|
val from = state(week, listOf(bar))
|
||||||
|
val to = state(day, emptyList())
|
||||||
|
val placed = morphBars(from, to).single()
|
||||||
|
assertThat(placed.fromRange).isEqualTo(placed.toRange)
|
||||||
|
assertThat(placed.toLane).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar on both pages is placed once, clipped to each`() {
|
||||||
|
val event = event(1)
|
||||||
|
val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0)))
|
||||||
|
val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0)))
|
||||||
|
val placed = morphBars(from, to).single()
|
||||||
|
assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY))
|
||||||
|
assertThat(placed.toRange).isEqualTo(monday..monday)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun state(span: DaySpan, bars: List<AllDaySpan>): WeekUiState.Success {
|
||||||
|
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||||
|
return WeekUiState.Success(
|
||||||
|
weekStart = span.start,
|
||||||
|
today = sunday,
|
||||||
|
days = days,
|
||||||
|
allDaySpans = bars,
|
||||||
|
timedByDay = emptyMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun event(id: Long) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Event",
|
||||||
|
start = Instant.fromEpochMilliseconds(0),
|
||||||
|
end = Instant.fromEpochMilliseconds(0),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF000000.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Where the focused date goes as a timeline pages (#184). */
|
||||||
|
class FocusOnPageTest {
|
||||||
|
|
||||||
|
// 2026-09-30 is a Wednesday.
|
||||||
|
private val wednesday = LocalDate(2026, 9, 30)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a page that still shows the focus keeps it`() {
|
||||||
|
val grid = WeekGrid(DayOfWeek.MONDAY)
|
||||||
|
assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a week on keeps the weekday`() {
|
||||||
|
val grid = WeekGrid(DayOfWeek.MONDAY)
|
||||||
|
val page = grid.pageFor(wednesday)
|
||||||
|
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7))
|
||||||
|
assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a run of days keeps the focus's column`() {
|
||||||
|
val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3)
|
||||||
|
val page = grid.pageFor(wednesday)
|
||||||
|
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day page is the focus`() {
|
||||||
|
val page = DayGrid.pageFor(wednesday)
|
||||||
|
assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,4 +64,18 @@ class WeekPagingTest {
|
|||||||
assertThat(grid.startForPage(page)).isEqualTo(date)
|
assertThat(grid.startForPage(page)).isEqualTo(date)
|
||||||
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
|
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day page and its day round-trip`() {
|
||||||
|
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
|
||||||
|
assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent day pages are adjacent days across a month end`() {
|
||||||
|
val page = DayGrid.pageFor(LocalDate(2026, 2, 28))
|
||||||
|
assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
|
||||||
|
assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-3
@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
|||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph UI ["ui/ — Compose screens + ViewModels"]
|
subgraph UI ["ui/ — Compose screens + ViewModels"]
|
||||||
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"]
|
Screens["Month / Timeline / Agenda\nDetail / Edit / Settings\nOnboarding wizard"]
|
||||||
end
|
end
|
||||||
subgraph Data ["data/"]
|
subgraph Data ["data/"]
|
||||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||||
@@ -79,10 +79,42 @@ flowchart TD
|
|||||||
|
|
||||||
There is no navigation library. `MainActivity` hosts `RootScreen`, which
|
There is no navigation library. `MainActivity` hosts `RootScreen`, which
|
||||||
gates on the first-launch wizard (`ui/onboarding/`), then shows
|
gates on the first-launch wizard (`ui/onboarding/`), then shows
|
||||||
`CalendarHost`. `CalendarHost` holds the active view (month/week/day)
|
`CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week,
|
||||||
plus overlay state for detail, edit, and settings — full-screen overlays
|
agenda) plus overlay state for detail, edit, and settings — full-screen overlays
|
||||||
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
|
||||||
key stays alive through the slide-out so content never flashes empty.
|
key stays alive through the slide-out so content never flashes empty.
|
||||||
|
Overlays leave through floret-kit's `predictiveBackExit`, so a committed back
|
||||||
|
gesture finishes from its scaled preview.
|
||||||
|
|
||||||
|
### Switching views (#184)
|
||||||
|
|
||||||
|
- **One focused date.** `CalendarHost` owns a `ViewFocus` (`LocalViewFocus`)
|
||||||
|
that every view opens on and carries along. A view *reads* it once, on
|
||||||
|
entry, synchronously (`EnterOnFocus` before it reads its own position, so
|
||||||
|
its first frame is already on that date), and *writes* it only when the user
|
||||||
|
moves — a pager settling (`focusOnPage` keeps the date's place in the page),
|
||||||
|
a jump, Today, a tapped day, the agenda's top row. Writing on entry would
|
||||||
|
drift the date on every round trip.
|
||||||
|
- **Three screens, not five.** The host's `AnimatedContent` is keyed on
|
||||||
|
`ViewScreen` (month, timeline, agenda), not on the view: day, multi-day and
|
||||||
|
week are one `TimelineScreen` with a `TimelineKind` each and a view model
|
||||||
|
each. Keying the three on one `contentKey` instead would still replay the
|
||||||
|
enter transition on every switch among them.
|
||||||
|
- **Timeline ↔ timeline resizes in place.** `ColumnGeometry` places every
|
||||||
|
column (header cell, all-day bar, day column) in the layout pass; a switch
|
||||||
|
swaps the pager for a frame over both pages' days, each column lerped
|
||||||
|
between its slot on the one page and the other — a day the page doesn't
|
||||||
|
show carries on at its pitch, off screen, which is what slides it in or
|
||||||
|
out. Blocks sit in their column as shares of its width (`LaneColumn`), so
|
||||||
|
nothing recomposes per frame. The frame starts and ends exactly as the two
|
||||||
|
pages lay out, so the pagers either side hand over without a jump.
|
||||||
|
- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`,
|
||||||
|
an event shown in both views travels from its old bounds to its new ones,
|
||||||
|
keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a
|
||||||
|
pager's settled page carries the tags. The drawer, top bar and FAB are not
|
||||||
|
per screen: CalendarHost draws them once, above the switch, from what the
|
||||||
|
screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a
|
||||||
|
switch only changes what they say.
|
||||||
A tapped reminder notification routes through `MainActivity` (`singleTop` +
|
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.
|
||||||
|
|||||||
+1
-1
Submodule floret-kit updated: 14c13bae6d...b7e73ee880
Reference in New Issue
Block a user