diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index f919d49..bc098c9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -288,6 +288,15 @@ class SettingsPrefs @Inject constructor( store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } } + /** How many days the multi-day view shows side by side (#193). */ + val multiDayCount: Flow = 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 * 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 = 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 * historical layout, where it's an extended FAB that fades in above the "+" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index d0ece8a..492343d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -3,22 +3,36 @@ package de.jeanlucmakiola.calendula.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.screen +import de.jeanlucmakiola.calendula.ui.common.ViewScreen +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.lifecycle.compose.collectAsStateWithLifecycle 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.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen +import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip +import de.jeanlucmakiola.calendula.ui.common.StayingPut +import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFab +import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar +import de.jeanlucmakiola.calendula.ui.common.ChromeHost +import de.jeanlucmakiola.calendula.ui.common.ChromeSlot +import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot +import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.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.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove 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.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.drillToWeek import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.selectView 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.edit.EventEditScreen 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.search.SearchScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen -import de.jeanlucmakiola.calendula.ui.week.MultiDayScreen -import de.jeanlucmakiola.calendula.ui.week.WeekScreen +import de.jeanlucmakiola.calendula.ui.week.TimelineScreen +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone 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 - * screens. Each screen owns its own ViewModel and date anchor; the view-switcher - * pill in their top bars writes back here via [onSelectView]. + * screens. Each screen owns its own ViewModel; they all open on the one focused + * 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. * 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 * widget's view.) */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CalendarHost( modifier: Modifier = Modifier, @@ -104,19 +139,33 @@ fun CalendarHost( var viewStack by rememberSaveable(stateSaver = viewStackSaver) { mutableStateOf(listOf(defaultView)) } + var navigationVersion by remember { mutableIntStateOf(0) } 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. - var pendingDayIso by rememberSaveable { mutableStateOf(null) } + // The date every view opens on and carries along (#184); tapping a day + // anywhere focuses it and opens the day view there. + val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } + + val switchScope = rememberCoroutineScope() + val switchTo: ((List) -> List) -> Unit = { move -> + navigationVersion++ + viewStack = move(viewStack) + } + val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } val onOpenDay: (LocalDate) -> Unit = { date -> - pendingDayIso = date.toString() - viewStack = viewStack.drillToDay() + focus.navigateTo(date) + switchTo { it.drillToDay() } } - var pendingWeekIso by rememberSaveable { mutableStateOf(null) } val onOpenWeek: (LocalDate) -> Unit = { date -> - pendingWeekIso = date.toString() - viewStack = viewStack.drillToWeek() + focus.navigateTo(date) + switchTo { it.drillToWeek() } } // 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 // view so backing out returns there (then home), not to the default. LaunchedEffect(widgetNavRequest) { + if (widgetNavRequest != null) navigationVersion++ when (val req = widgetNavRequest) { is WidgetNavRequest.OpenDate -> { // 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. dismissCoveringOverlays() createDateIso = null - pendingDayIso = req.dateIso + focus.navigateTo(LocalDate.parse(req.dateIso)) // No widget source (an external date tap) roots over the default // home view, so backing out of the day returns home then exits. viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() @@ -296,7 +346,7 @@ fun CalendarHost( // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() createDateIso = null - pendingDayIso = null + focus.navigateTo(localToday()) viewStack = viewBaseStack(defaultView, req.view ?: defaultView) onWidgetNavConsumed() } @@ -347,6 +397,9 @@ fun CalendarHost( } 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 // owns its own BackHandler and takes precedence). Disabled at the home view, @@ -355,90 +408,129 @@ fun CalendarHost( editKey != null || showSettings || showCalendars || showBackup || importUri != null || importForm != null BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { + navigationVersion++ viewStack = viewStack.dropLast(1) } 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 - // 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 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( LocalEventMove provides moveScope, + LocalTimelineScroll provides timelineScroll, + LocalViewFocus provides focus, // The failure state's way out when every calendar is switched off // (#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( - targetState = view, + targetState = view.screen, transitionSpec = { viewSwitch }, label = "view-switch", - ) { currentView -> - when (currentView) { - CalendarView.Week -> WeekScreen( - selectedView = currentView, - onSelectView = onSelectView, + ) { screen -> + val sharedScope = this@SharedTransitionLayout + val visibilityScope = this@AnimatedContent + val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) { + if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) + } + CompositionLocalProvider( + LocalViewMorph provides viewMorph, + LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen), + ) { + when (screen) { + ViewScreen.Timeline -> TimelineScreen( + view = timelineView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - initialDateIso = pendingWeekIso, - onInitialDateConsumed = { pendingWeekIso = null }, ) - CalendarView.MultiDay -> MultiDayScreen( - 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, + ViewScreen.Month -> MonthScreen( onOpenDay = onOpenDay, onOpenWeek = onOpenWeek, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) - CalendarView.Agenda -> AgendaScreen( - selectedView = currentView, - onSelectView = onSelectView, + ViewScreen.Agenda -> AgendaScreen( onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) } + } + } + } + } } } @@ -451,11 +543,12 @@ fun CalendarHost( AnimatedVisibility( visible = showSearch, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SearchScreen( onBack = { showSearch = false }, onEventClick = onEventClick, + visible = showSearch, ) } @@ -464,7 +557,7 @@ fun CalendarHost( AnimatedVisibility( visible = detailKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { activeKey?.let { key -> EventDetailScreen( @@ -484,6 +577,7 @@ fun CalendarHost( importForm = form detailKey = null }, + visible = detailKey != null, ) } } @@ -492,7 +586,7 @@ fun CalendarHost( AnimatedVisibility( visible = createDateIso != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (createDateIso ?: heldCreateIso)?.let { iso -> EventEditScreen( @@ -501,6 +595,7 @@ fun CalendarHost( onClose = { createDateIso = null }, onSaved = { createDateIso = null }, onManageCalendars = { showCalendars = true }, + visible = createDateIso != null, ) } } @@ -509,7 +604,7 @@ fun CalendarHost( AnimatedVisibility( visible = editKey != null, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { (editKey ?: heldEditKey)?.let { key -> EventEditScreen( @@ -521,6 +616,7 @@ fun CalendarHost( detailKey = null }, onManageCalendars = { showCalendars = true }, + visible = editKey != null, ) } } @@ -529,12 +625,13 @@ fun CalendarHost( AnimatedVisibility( visible = showSettings, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { SettingsScreen( onBack = { showSettings = false }, onManageCalendars = { showCalendars = true }, onOpenBackup = { showBackup = true }, + visible = showSettings, ) } @@ -586,11 +683,12 @@ fun CalendarHost( AnimatedVisibility( visible = showCalendars, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { CalendarsScreen( onBack = { showCalendars = false }, onOpenBackup = { showBackup = true }, + visible = showCalendars, ) } @@ -598,7 +696,7 @@ fun CalendarHost( AnimatedVisibility( visible = showBackup, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { // Settings can open this screen without the manager underneath, so // its failure states' "Manage calendars" way out has to open the @@ -625,6 +723,7 @@ fun CalendarHost( showBackup = false showCalendars = false }, + visible = showBackup, ) } } @@ -636,3 +735,6 @@ private val viewStackSaver = listSaver, Int>( save = { stack -> stack.map(CalendarView::ordinal) }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, ) + +private fun localToday(): LocalDate = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index bb4ef71..737b023 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,5 +1,18 @@ 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.layout.Arrangement 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.material.icons.Icons import androidx.compose.material.icons.filled.Coffee -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -58,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.floret.locale.currentLocale @@ -81,26 +77,22 @@ import kotlin.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun AgendaScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { + // 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 anchor by viewModel.anchor.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() var showRangePicker by remember { mutableStateOf(false) } @@ -124,112 +116,88 @@ fun AgendaScreen( // requested: scrolling before the new rows compose leaves the list keyed to // a row that reappears further down, and it follows it there. LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) } - val jumpToToday = { + val jumpToToday: () -> Unit = { // 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. if (isOnToday) scope.launch { listState.animateScrollToItem(0) } viewModel.goToToday() + (state as? AgendaUiState.Success)?.today?.let { focus?.date = it } } - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = anchor, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - 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, - ) + PublishChrome( + ScreenChrome( + title = stringResource(R.string.view_agenda), + titleDate = null, + drawerDate = anchor, + awayFromToday = !isOnToday || (scrolledAway && successState != null), + todayText = stringResource(R.string.agenda_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = { target -> + focus?.date = target + viewModel.goToDate(target) }, - floatingActionButton = { - CalendarFabColumn( - todayVisible = (!isOnToday || (scrolledAway && successState != null)) && - !todayInToolbar, - todayText = stringResource(R.string.agenda_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(anchor, null) }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - // One bar at the top: the "showing …" header on the left and the - // session range switcher on the right (one settings toggle). - successState?.takeIf { it.showRangeBar }?.let { s -> - // end lines the selector up with whatever ends the top bar: - // the view switcher's background, or — once #150 hides it — - // the search icon's glyph. - val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { - AppBarSpacing.Inset - } else { - AppBarSpacing.IconTrailingInset - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding( - start = RANGE_BAR_TEXT_INSET, - end = selectorEnd, - top = 8.dp, - bottom = 8.dp, - ), - ) { - AgendaRangeBanner( - range = s.range, - start = s.anchor, - end = s.rangeEnd, - modifier = Modifier.weight(1f), - ) - AgendaRangePill( - range = s.range, - isOverride = s.rangeIsOverride, - onClick = { showRangePicker = true }, - ) - } + onCreate = { onCreateEvent(anchor, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // One bar at the top: the "showing …" header on the left and the + // session range switcher on the right (one settings toggle). + successState?.takeIf { it.showRangeBar }?.let { s -> + // end lines the selector up with whatever ends the top bar: + // the view switcher's background, or — once #150 hides it — + // the search icon's glyph. + val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) { + AppBarSpacing.Inset + } else { + AppBarSpacing.IconTrailingInset } - AgendaContent( - state = state, - pastDisplay = pastDisplay, - showToday = showToday, - listState = listState, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) + .fillMaxWidth() + .padding( + start = RANGE_BAR_TEXT_INSET, + end = selectorEnd, + top = 8.dp, + bottom = 8.dp, + ), + ) { + AgendaRangeBanner( + range = s.range, + start = s.anchor, + end = s.rangeEnd, + modifier = Modifier.weight(1f), + ) + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + ) + } } + AgendaContent( + state = state, + pastDisplay = pastDisplay, + showToday = showToday, + listState = listState, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onTopDay = if (successState?.anchor == anchor) { + { focus?.date = it } + } else null, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) } } @@ -326,6 +294,7 @@ internal fun AgendaContent( onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), + onTopDay: ((LocalDate) -> Unit)? = null, ) { when (state) { AgendaUiState.Loading -> Box(modifier) @@ -368,6 +337,7 @@ internal fun AgendaContent( now = now, onEventClick = onEventClick, onOpenDay = onOpenDay, + onTopDay = onTopDay, modifier = modifier, ) } @@ -386,8 +356,20 @@ private fun AgendaList( now: Instant, onEventClick: (EventInstance) -> Unit, onOpenDay: (LocalDate) -> Unit, + onTopDay: ((LocalDate) -> Unit)?, 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( modifier = modifier, state = listState, @@ -420,7 +402,7 @@ private fun AgendaList( zone = zone, position = positionOf(index, day.events.size), dimmed = dimPast && event.hasEnded(now), - modifier = animateItemMotion(), + modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), 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): List = + days.flatMap { day -> + // Header, the placeholder or the events, then the gap. + List(2 + day.events.size.coerceAtLeast(1)) { day.date } + } + @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( @@ -451,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { ) } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AgendaTopBar( - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - TopAppBar( - title = { - // A plain label rather than a CalendarTitleButton, so it takes that - // one's inset and one-line clamp itself. - Text( - text = stringResource(R.string.view_agenda), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.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, - ), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt index 7a3af49..2da55f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/BackupScreen.kt @@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf( fun BackupScreen( onBack: () -> Unit, onImport: (Uri) -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val state by viewModel.backupState.collectAsStateWithLifecycle() @@ -137,6 +138,7 @@ fun BackupScreen( scrollable = state is BackupUiState.Ready, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { when (val s = state) { BackupUiState.Loading -> BackupLoading() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index 7d00c20..1c3f66f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE fun CalendarsScreen( onBack: () -> Unit, onOpenBackup: () -> Unit, + visible: Boolean = true, viewModel: CalendarsViewModel = hiltViewModel(), ) { val calendars by viewModel.calendars.collectAsStateWithLifecycle() @@ -177,6 +178,7 @@ fun CalendarsScreen( onConsumeError = viewModel::consumeError, onOpenBackup = onOpenBackup, onBack = onBack, + visible = visible, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetVisible = viewModel::setCalendarVisible, @@ -193,6 +195,7 @@ private fun CalendarsList( onConsumeError: () -> Unit, onOpenBackup: () -> Unit, onBack: () -> Unit, + visible: Boolean, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetVisible: (Long, Boolean) -> Unit, @@ -218,6 +221,7 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, predictiveBack = true, + visible = visible, ) { // What the per-calendar / per-account switches below actually do. HintText(stringResource(R.string.calendars_visibility_hint)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index 4b1fcf7..d3b4566 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier 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.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration 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 de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlin.math.roundToInt /** Gap a timed block leaves to its neighbours in the column. */ 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 -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 @@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { - val spec: FiniteAnimationSpec = if (rememberReduceMotion() || - LocalTimelineZoom.current.isPinching - ) { - snap() - } else { - MaterialTheme.motionScheme.fastSpatialSpec() +fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement { + val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching + val dpSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val shareSpec: FiniteAnimationSpec = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x") + val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") + 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt new file mode 100644 index 0000000..173d29d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarChrome.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import kotlinx.datetime.LocalDate + +/** + * What a calendar screen shows in the top bar, the FAB and the drawer, which the + * host draws once for all of them (#184): switching screens changes what they + * say rather than swapping one bar for another. + * + * @param titleDate the date the title's picker opens on, or null for a title + * that is only a label. + * @param drawerDate the date the drawer's picker opens on. + * @param awayFromToday whether "today" is worth offering. + */ +@Immutable +data class ScreenChrome( + val title: String, + val shortTitle: String = title, + val titleDate: LocalDate?, + val drawerDate: LocalDate, + val awayFromToday: Boolean, + val todayText: String, +) + +/** What the chrome does for the screen on show; read when used, so not observed. */ +class ScreenActions( + val onToday: () -> Unit, + val onJumpToDate: (LocalDate) -> Unit, + val onCreate: () -> Unit, +) + +/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ +@Stable +class ChromeHost { + var chrome by mutableStateOf(null) + private set + + var actions: ScreenActions? = null + private set + + internal fun publish(chrome: ScreenChrome, actions: ScreenActions) { + this.chrome = chrome + this.actions = actions + } +} + +/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */ +class ChromeSlot(val host: ChromeHost, val current: Boolean) + +val LocalChromeSlot = staticCompositionLocalOf { null } + +/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */ +@Composable +fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) { + val slot = LocalChromeSlot.current ?: return + if (slot.current) SideEffect { slot.host.publish(chrome, actions) } +} + +/** The top bar every calendar screen shares. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarTopBar( + host: ChromeHost, + selectedView: CalendarView, + quickSwitchViews: List, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + onOpenSearch: () -> Unit, + showTodayButton: Boolean, +) { + val chrome = host.chrome + TopAppBar( + title = { + if (chrome != null) { + CalendarTitleButton( + title = chrome.title, + currentDate = chrome.titleDate, + onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) }, + shortTitle = chrome.shortTitle, + ) + } + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() }) + IconButton(onClick = onOpenSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search_action), + ) + } + ViewSwitcherPill( + current = selectedView, + cycle = quickSwitchViews, + onCycle = onCycleView, + ) + }, + // Deliberately flat: M3 lifts the bar to mark content scrolling under + // it, but here the bar meets the header on the same surface and the + // tint is what makes that seam look like a separate block (#186). + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} + +/** The FAB column every calendar screen shares. */ +@Composable +fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { + val chrome = host.chrome ?: return + CalendarFabColumn( + todayVisible = chrome.awayFromToday && !todayInToolbar, + todayText = chrome.todayText, + onToday = { host.actions?.onToday?.invoke() }, + onCreate = { host.actions?.onCreate?.invoke() }, + ) +} + +/** + * A calendar screen's body under the shared chrome, on the ground the screen's + * own Scaffold used to give it; the host's Scaffold has already made room for + * the bars, so [content] gets no padding of its own. + */ +@Composable +fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) { + Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + content(PaddingValues()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt index 305f0ec..3f4f890 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTitleButton.kt @@ -1,5 +1,13 @@ 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.layout.BoxWithConstraints 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.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate * drawer uses and reporting the chosen day through [onJumpToDate]. * * [currentDate] seeds the picker with whatever the bar is currently naming (the - * visible day, week start or month anchor). + * visible day, week start or month anchor); null makes the title a plain label, + * as the agenda's is, and the caret folds away. * * [shortTitle] replaces [title] when the full one does not fit the width the app * bar hands the title slot (#165). Either way the line clamps to one and @@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate @Composable fun CalendarTitleButton( title: String, - currentDate: LocalDate, + currentDate: LocalDate?, onJumpToDate: (LocalDate) -> Unit, modifier: Modifier = Modifier, shortTitle: String = title, @@ -54,6 +63,7 @@ fun CalendarTitleButton( .heightIn(min = 48.dp) .clip(MaterialTheme.shapes.large) .clickable( + enabled = currentDate != null, onClickLabel = stringResource(R.string.drawer_jump_to_date), role = Role.Button, ) { showDatePicker = true } @@ -71,21 +81,38 @@ fun CalendarTitleButton( availableWidth = constraints.maxWidth, ) } - Text( - text = shown, - style = style, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + // Cross-fades, so switching views or paging turns the title over + // rather than snapping it (#184). + val fadeSpec = rememberCalendarFadeSpec() + AnimatedContent( + 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( initial = currentDate, onConfirm = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 948d1f2..9c5c52b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec = @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = MaterialTheme.motionScheme.defaultEffectsSpec() + +/** + * A switch between views (#184): the default spatial spring, a little softer, as + * the whole screen reshapes rather than a page sliding by; softer still when + * [long], for a change the eye has further to follow. + */ +@Composable +fun rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec = + spring( + dampingRatio = VIEW_SWITCH_DAMPING, + stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS, + ) + +private const val VIEW_SWITCH_DAMPING = 0.9f +private const val VIEW_SWITCH_STIFFNESS = 450f +private const val LONG_VIEW_SWITCH_STIFFNESS = 340f diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 8574285..7715ee2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -130,6 +130,15 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List.drillToDay(): List = 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. */ fun List.drillToWeek(): List = if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt index 330fb43..fe933e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGrid.kt @@ -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 * 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) { this } else { @@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), ) val cellSize = Size(size.width, cellHeight) + val top = inset for (hour in 0 until 24) { drawRoundRect( color = color, - topLeft = Offset(0f, hour * hourHeightPx + inset), + topLeft = Offset(0f, hour * hourHeightPx + top), size = cellSize, cornerRadius = radius, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt new file mode 100644 index 0000000..eaf19ff --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -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 { 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 { 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index 75367b9..3b6d3e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -13,15 +13,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -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.abs import kotlin.math.floor import kotlin.math.roundToInt @@ -285,31 +281,3 @@ internal fun anchoredScroll( * as a scroll stays one. */ 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) - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt index 583ee4a..d1b27d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TodayAction.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text +import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) { val description = stringResource(R.string.today_jump_action) IconButton( onClick = onToday, - modifier = Modifier.semantics { contentDescription = description }, + modifier = Modifier + .semantics { contentDescription = description }, ) { val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt new file mode 100644 index 0000000..27bf4a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewFocus.kt @@ -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 = Saver( + save = { it.date.toString() }, + restore = { ViewFocus(LocalDate.parse(it)) }, +) + +val LocalViewFocus = staticCompositionLocalOf { 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 EnterOnFocus(key: T, enter: (LocalDate) -> Unit) { + val focus = LocalViewFocus.current + remember(key) { focus?.date?.also(enter) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt new file mode 100644 index 0000000..1faa9a8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -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, + val clip: BelowTopBarClip, + val stayingPut: StayingPut, +) + +/** + * The events a view has but keeps out of the switch, being scrolled out of + * sight: the other view leaves them untagged too, or its half would travel to + * nowhere and snap into place at the end. + */ +class StayingPut { + val keys = mutableStateSetOf() +} + +/** Marks [key] as staying put while [stays], for as long as it is composed. */ +@Composable +fun StayPut(key: ViewMorphKey.Event, stays: Boolean) { + val stayingPut = LocalViewMorph.current?.stayingPut ?: return + if (!stays) return + DisposableEffect(stayingPut, key) { + stayingPut.keys += key + onDispose { stayingPut.keys -= key } + } +} + +/** + * Keeps what travels below the top bar, which every view has at the same height: + * the overlay draws above the whole screen, so an event scrolled under the bar + * 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 { 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() + return remember(spec) { BoundsTransform { _, _ -> spec } } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt index 10e17c4..b33d28c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -1,12 +1,17 @@ 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.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp /** @@ -30,11 +35,23 @@ fun ViewSwitcherPill( trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, ) { if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return + val fadeSpec = rememberCalendarFadeSpec() FilledTonalButton( onClick = onCycle, 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) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt deleted file mode 100644 index 3544cd0..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ /dev/null @@ -1,1023 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.day - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerDefaults -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.mutableStateMapOf -import de.jeanlucmakiola.calendula.ui.common.jumpToPage -import de.jeanlucmakiola.calendula.ui.common.pagedHeight -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec -import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.drop -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.ui.common.LocalChipGround -import de.jeanlucmakiola.calendula.ui.common.eventPaint -import de.jeanlucmakiola.calendula.ui.common.eventSurface -import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction -import de.jeanlucmakiola.calendula.ui.common.CalendarFailure -import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS -import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET -import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING -import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel -import de.jeanlucmakiola.calendula.ui.common.BlockTitle -import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics -import de.jeanlucmakiola.calendula.ui.common.blockTimeLines -import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement -import de.jeanlucmakiola.calendula.ui.common.ghostAlpha -import de.jeanlucmakiola.calendula.ui.common.LocalEventMove -import de.jeanlucmakiola.calendula.ui.common.MoveRequest -import de.jeanlucmakiola.calendula.ui.common.MoveTarget -import de.jeanlucmakiola.calendula.ui.common.NowLine -import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars -import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges -import de.jeanlucmakiola.calendula.ui.common.TimelineDragController -import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay -import de.jeanlucmakiola.calendula.ui.common.TimelineDrop -import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes -import de.jeanlucmakiola.calendula.ui.common.continuesAfter -import de.jeanlucmakiola.calendula.ui.common.continuesBefore -import de.jeanlucmakiola.calendula.ui.common.ChipCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts -import de.jeanlucmakiola.calendula.ui.common.timedBlockShape -import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed -import de.jeanlucmakiola.calendula.ui.common.eventMoveAction -import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow -import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource -import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController -import de.jeanlucmakiola.calendula.ui.common.startInstant -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec -import de.jeanlucmakiola.floret.identity.rememberReduceMotion -import de.jeanlucmakiola.calendula.ui.common.next -import de.jeanlucmakiola.calendula.ui.common.EventChipShape -import de.jeanlucmakiola.calendula.ui.common.trimmedLines -import de.jeanlucmakiola.floret.locale.currentLocale -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid -import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll -import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION -import de.jeanlucmakiola.calendula.ui.common.hourHeight -import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom -import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH -import de.jeanlucmakiola.calendula.ui.common.HourGutter -import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET -import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset -import de.jeanlucmakiola.calendula.ui.common.hourGridCells -import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import java.util.Locale -import kotlin.math.roundToInt - -/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ -private val ALL_DAY_ROW_HEIGHT = 20.dp -private val ALL_DAY_VERTICAL_PADDING = 6.dp - -/** Breathing room between the all-day strip and the scrolling timeline below. */ -private val TIMELINE_TOP_GAP = 8.dp - -/** Total all-day strip height for the day (0 when there are no all-day events). */ -internal fun DayUiState.Success.allDayStripHeight(): Dp { - if (allDay.isEmpty()) return 0.dp - val lanes = allDay.maxOf { it.lane } + 1 - return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DayScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, - onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, - onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, - modifier: Modifier = Modifier, - initialDateIso: String? = null, - viewModel: DayViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle() - - // When opened from the month grid, anchor to the tapped date. - LaunchedEffect(initialDateIso) { - initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } - } - - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - - // Opens on the tapped date directly, rather than on today and then correcting. - val pagerState = rememberPagerState( - initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage, - ) { DAY_PAGE_COUNT } - val pageSpec = rememberCalendarPageSpec() - val reduceMotion = rememberReduceMotion() - // The pager leads and the anchor follows once it settles, so the anchor only - // moves on its own to correct the pager — e.g. re-entry from the month grid - // on another date. Snapped, since that is a correction rather than a move. - LaunchedEffect(anchorPage) { - if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) { - pagerState.scrollToPage(anchorPage) - } - } - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) - } - // The day under the finger, so the title turns over mid-swipe, not after it. - val date = dayForPage(pagerState.currentPage) - - // Off the ticking clock rather than the loaded day, so it turns over at - // midnight; derived, so the per-minute tick doesn't recompose the screen. - val nowState = rememberCurrentMinute() - val zone = remember { TimeZone.currentSystemDefault() } - val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } } - val isOnToday = state !is DayUiState.Success || date == today - - // Straight to the pager: a tap mid-fling must still land, and the anchor - // hasn't caught up with the fling yet to tell the difference. - val jumpToDate: (LocalDate) -> Unit = { target -> - scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) } - } - val jumpToToday = { jumpToDate(today) } - - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the day swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = date, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - DayTopBar( - date = date, - currentYear = today.year, - 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 = !isOnToday && !todayInToolbar, - todayText = stringResource(R.string.day_today_action), - onToday = jumpToToday, - onCreate = { onCreateEvent(date, null) }, - ) - }, - ) { innerPadding -> - DayContent( - state = state, - pagerState = pagerState, - day = viewModel::day, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } - } -} - -@Composable -private fun DayContent( - state: DayUiState, - pagerState: PagerState, - day: (LocalDate) -> StateFlow, - onRetry: () -> Unit, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - modifier: Modifier = Modifier, -) { - val fadeSpec = rememberCalendarFadeSpec() - - // Shared by every page and the gutter, so the vertical scroll position - // survives day-to-day swipes. Positioned once on entry into the day view: - // at the visible hours' start if set (#322), otherwise noon. - val scrollState = rememberScrollState() - OpenTimelineScroll(scrollState, LocalTimelineZoom.current) - - // Above the pager: a page change mid-drag would strand the floating block - // inside the outgoing page. - val dragController = rememberTimelineDragController() - val move = LocalEventMove.current - val zone = remember { TimeZone.currentSystemDefault() } - val onDrop: (TimelineDrop) -> Unit = { drop -> - move?.move( - MoveRequest( - eventId = drop.event.eventId, - beginMillis = drop.event.start.toEpochMilliseconds(), - endMillis = drop.event.end.toEpochMilliseconds(), - target = MoveTarget.Start(drop.startInstant(zone)), - ), - ) - } - - Box(modifier = modifier) { - // Loading and failure concern the provider as a whole, so they stand in - // for the pager; a page still loading its own day handles that itself. - AnimatedContent( - targetState = state, - modifier = Modifier.fillMaxSize(), - contentKey = { it::class }, - transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, - label = "day-state", - ) { s -> - when (s) { - DayUiState.Loading -> DayLoading() - is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is DayUiState.Success -> DayPager( - pagerState = pagerState, - day = day, - today = s.today, - initialStripHeight = s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } - } - TimelineDragOverlay(dragController) - } -} - -/** - * The days as pages that follow the finger and snap once a swipe passes half a - * page or is flung (#336), beside a gutter that stays put. - */ -@Composable -private fun DayPager( - pagerState: PagerState, - day: (LocalDate) -> StateFlow, - today: LocalDate, - initialStripHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - val stripHeights = remember { mutableStateMapOf() } - val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) - DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - // The days either side are composed ahead, so a swipe lands on a - // loaded day rather than watching it fill in. - beyondViewportPageCount = 1, - // A held block moves within its day; the page stays put under it. - userScrollEnabled = !dragController.isDragging, - flingBehavior = PagerDefaults.flingBehavior( - state = pagerState, - snapAnimationSpec = rememberCalendarPageSpec(), - ), - ) { page -> - val date = dayForPage(page) - val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle() - val loaded = pageState as? DayUiState.Success - // Until its day arrives a page shows the empty column. - val empty = remember(date, today) { - DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList()) - } - val stripHeight = loaded?.allDayStripHeight() - DisposableEffect(page, stripHeight) { - if (stripHeight != null) stripHeights[page] = stripHeight - onDispose { stripHeights.remove(page) } - } - DayPage( - state = loaded ?: empty, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = page == pagerState.currentPage, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } - } -} - -/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */ -@Composable -private fun DayFrame( - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - pages: @Composable () -> Unit, -) { - Row(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { - Spacer( - Modifier - .fillMaxWidth() - .height(allDayHeight) - .background(MaterialTheme.colorScheme.surface), - ) - Spacer(Modifier.height(TIMELINE_TOP_GAP)) - // Resolves the hour height off the same viewport height the pages' - // timelines have, so labels and column agree. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) - // Start inset so the labels centre on the top bar hamburger, - // matching the week view. - HourGutter( - scrollState = scrollState, - hourHeight = hourHeight, - dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), - ) - } - } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } - } -} - -/** A single day in its frame, without the pager — for the Settings preview. */ -@Composable -internal fun DaySuccess( - state: DayUiState.Success, - scrollState: ScrollState, - allDayHeight: Dp, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) { - DayPage( - state = state, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = true, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } -} - -/** - * One day's page: all-day strip and column. - * - * @param active whether this is the page on screen, the only one that may - * publish the drag geometry. - */ -@Composable -private fun DayPage( - state: DayUiState.Success, - allDayHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - active: Boolean, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - Column(modifier = Modifier.fillMaxSize()) { - // All-day strip collapses to nothing when the day has no all-day events, - // so the timeline sits directly under the app bar. - // This strip is painted on `surface`, so an outlined chip has to fill - // with that and not with the column's container (#230). - CompositionLocalProvider( - LocalChipGround provides MaterialTheme.colorScheme.surface, - ) { - AllDayStrip( - state = state, - height = allDayHeight, - onEventClick = onEventClick, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), - ) - } - Spacer(Modifier.height(TIMELINE_TOP_GAP)) - Timeline( - state = state, - scrollState = scrollState, - dragController = dragController, - active = active, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DayTopBar( - date: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(date, locale, currentYear) { - formatDayTitle(date, locale, currentYear) to - formatDayTitle(date, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = date, - 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 -private fun AllDayStrip( - state: DayUiState.Success, - height: Dp, - onEventClick: (EventInstance) -> Unit, - modifier: Modifier = Modifier, -) { - val dark = isSystemInDarkTheme() - - Row( - modifier = modifier - // Height is hoisted + animated so it resizes smoothly; padding sits - // inside it so the content area is lanes * row height. - .height(height) - .padding( - top = ALL_DAY_VERTICAL_PADDING, - bottom = ALL_DAY_VERTICAL_PADDING, - end = TIMELINE_CONTENT_END_INSET, - ), - ) { - // Bars are positioned absolutely by lane (vertical stacking); each spans - // the full day-column width. clipToBounds keeps bars from spilling out - // while the height animates. - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clipToBounds(), - ) { - val barWidth = maxWidth - state.allDay.forEach { span -> - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset(y = ALL_DAY_ROW_HEIGHT * span.lane) - .width(barWidth) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) - } - } - } -} - -@Composable -private fun AllDayBar( - event: EventInstance, - dark: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - val paint = eventPaint(event, dark) - Box( - modifier = modifier - .eventSurface(paint, EventChipShape) - .clickable(onClick = onClick) - .padding(horizontal = 6.dp, vertical = 2.dp) - .semantics { contentDescription = title }, - contentAlignment = Alignment.CenterStart, - ) { - val titleOverflow = eventTitleOverflow() - Text( - text = title, - style = MaterialTheme.typography.labelSmall.trimmedLines(), - maxLines = 1, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, - color = paint.titleInk, - fontWeight = paint.titleWeight, - textDecoration = paint.decoration, - ) - } -} - -@Composable -private fun Timeline( - state: DayUiState.Success, - scrollState: ScrollState, - dragController: TimelineDragController, - active: Boolean, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, -) { - val dark = isSystemInDarkTheme() - val zoom = LocalTimelineZoom.current - val density = LocalDensity.current - val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - // What the hour grid takes off a block's ends, for the floating copy to - // take off its own. Per-end, a cut edge keeps it — that much the drag - // geometry decides itself, from the slice it is drawing. - val blockInsetPx = with(density) { - hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx() - } - - // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the - // timeline's own viewport height, which is only known here — below the top - // bar, date header and all-day strip. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) - val totalHeight = hourHeight * 24 - // The pinch sits above the scroll viewport: it has to outrank the - // vertical scroll, and it does that by watching the initial pass, which - // only reaches it if it is the viewport's ancestor. - val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom) - // Scrolls on the same state as the gutter and the other pages, so they - // all stay aligned. A static, rounded-clipped window — the content - // scrolls inside it, so the soft corners are permanent at any scroll - // position. - Box( - modifier = Modifier - .fillMaxSize() - .then(pinch) - .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) - .verticalScroll(scrollState) - .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, - ) { - DayColumnCard( - blocks = state.timed, - dark = dark, - date = state.date, - today = state.today, - hourHeight = hourHeight, - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .fillMaxWidth() - .height(totalHeight) - // The scrolling content itself, so its root position - // already folds in the scroll offset. Only the page on - // screen publishes it: the ones either side are laid out too. - .onGloballyPositioned { coords -> - if (!active) return@onGloballyPositioned - dragController.geometry.let { - it.grid = coords - it.scroll = scrollState - it.hourPx = with(density) { hourHeight.toPx() } - it.blockInsetPx = blockInsetPx - it.columnGapPx = 0f - it.columnWidthPx = coords.size.width.toFloat() - it.days = listOf(state.date) - it.isRtl = isRtl - } - }, - ) - } - } -} - -@Composable -private fun DayColumnCard( - blocks: List, - dark: Boolean, - date: LocalDate, - today: LocalDate, - hourHeight: Dp, - scrollState: ScrollState, - dragController: TimelineDragController, - onEventClick: (EventInstance) -> Unit, - onCreateAt: (LocalDate, Int) -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - val showHourGrid = LocalShowHourGrid.current - val zone = remember { TimeZone.currentSystemDefault() } - // Tells a settled drop when this column has caught up with it. - LaunchedEffect(blocks, dragController.settling) { - dragController.noteGrid(date, blocks) - } - // The tap detector outlives the composition that installed it — a pinch or a - // Settings change moves the hour height without restarting it — so it reads - // the height and the callback through state handles instead of capturing - // them, or taps land on the scale the column had before the zoom (#148). - val currentHourPx = rememberUpdatedState(hourPx) - val currentOnCreateAt = rememberUpdatedState(onCreateAt) - val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx) - val currentEdges = rememberUpdatedState(edges) - Card( - // Plain rectangular column — the soft corners come from the outer - // rounded scroll viewport, so inner rounding would look odd at the edges. - shape = RectangleShape, - colors = CardDefaults.cardColors( - // With the grid on, the container colour moves onto the hour cells - // and the column behind them recedes to surface, so an hour boundary - // reads as negative space between two surfaces. - containerColor = if (showHourGrid) { - MaterialTheme.colorScheme.surface - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), - modifier = modifier, - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - // The hour cells sit over the column background but under the - // event blocks (drawBehind paints before the children). - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) - // Tap an empty slot to create an event there. Taps on event - // blocks are consumed by their own click handler first, so this - // only fires on the column background. Snaps to the tapped hour. - .pointerInput(date) { - detectTapGestures { offset -> - if (currentEdges.value.tap(offset.y)) return@detectTapGestures - currentOnCreateAt.value( - date, - tappedMinuteOfDay(offset.y, currentHourPx.value), - ) - } - }, - ) { - val colWidth = maxWidth - val minEventHeight = hourHeight * MIN_EVENT_FRACTION - // Keyed by event, so a block that changes time or lane is the *same* - // composable afterwards and tweens there. The ordinal disambiguates - // a column holding two occurrences of one series. - val ordinals = mutableMapOf() - blocks.forEach { block -> - val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 - key(block.event.eventId, ordinal) { - val laneWidth = colWidth / block.laneCount - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight - val place = animatedBlockPlacement( - x = laneWidth * block.lane, - y = top, - width = laneWidth, - height = height, - ) - // Where this block is cut at midnight, which decides both - // its shape and the half-gap it gives up to the hour cells. - val cuts = remember(block, date, zone) { - timedBlockCuts( - block.continuesBefore(date, zone), - block.continuesAfter(date, zone), - ) - } - val topInset = hourCellBlockInset(showHourGrid, cuts.top) - val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) - val blockHeight = (place.height - topInset - bottomInset) - .coerceAtLeast(0.dp) - EventBlock( - block = block, - dark = dark, - height = blockHeight, - width = place.width, - date = date, - cuts = cuts, - topInset = topInset, - dragController = dragController, - onClick = { onEventClick(block.event) }, - onDrop = onDrop, - modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .width(place.width) - .height(blockHeight) - .padding(horizontal = BLOCK_OUTER_INSET), - ) - } - } - // Current-time line, on top of the events, only on today's column. - if (date == today) { - NowLine(date = date, hourHeight = hourHeight) - } - OutOfViewEdgeBars( - edges = edges, - scrollState = scrollState, - dark = dark, - ) - } - } -} - -@Composable -private fun EventBlock( - block: TimedBlock, - dark: Boolean, - height: Dp, - width: Dp, - date: LocalDate, - cuts: ChipCuts, - topInset: Dp, - dragController: TimelineDragController, - onClick: () -> Unit, - onDrop: (TimelineDrop) -> Unit, - modifier: Modifier = Modifier, -) { - val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } - val use24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + - minToHm(block.endMin, use24Hour, locale) - val density = LocalDensity.current - val metrics = rememberBlockTextMetrics(height) - // A block that cannot afford both lines spends its space on the title, and - // one too short even for that drops the title rather than serving a sliced one. - // Height alone decides: a duration threshold would keep hiding the time on a - // half-hour block the user has pinched open to three times the room it needs. - val showTime = metrics.available >= metrics.titleLine + metrics.timeLine - val showTitle = metrics.fitsTitle - val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 - // Only lines the block can actually draw: a block too short for the time is - // too short for a second title line too, and asking for one served a sliced - // one — as well as handing the drag copy a count it couldn't honour, so the - // title re-wrapped the moment the block was lifted (#267). - val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1) - val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2) - // On a day column — wide enough for "09:30–11:00" several times over — the - // range never needs the second line, until lanes cut the column down. - val spare = metrics.available - metrics.titleHeight(titleMaxLines) - - if (showTime) metrics.timeLine else 0.dp - val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 - val paint = eventPaint(block.event, dark) - val zone = remember { TimeZone.currentSystemDefault() } - val moveAction = eventMoveAction(block.event) - val draggable = eventDragAllowed(block.event) - // The drop takes this offset back off, so a tail clipped at midnight lands - // where the event's own start belongs (#253). - val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } - val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } - val topInsetPx = with(density) { topInset.toPx() } - val dragModifier = rememberEventDragSource( - enabled = draggable, - key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> - dragController.begin( - block = block, - clipOffsetMin = clipOffset, - titleLines = if (showTitle) titleMaxLines else 0, - pointerInRoot = pointer, - // Measured off the block's placement rather than off where the - // hour grid seats it: the copy is drawn from that placement and - // re-seated the same half-gap down (#267). - blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx), - ) - }, - onMove = dragController::move, - onDrop = { dragController.finish()?.let(onDrop) }, - onCancel = dragController::cancel, - ) - val lifted = draggable && dragController.ghosts(block) - val ghost = ghostAlpha(lifted) - Box( - modifier = modifier - // The source stays put as a ghost while its floating copy travels. - .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .eventSurface(paint, shape, cuts) - .clickable(onClick = onClick) - // After clickable, so it is the inner node and wins the main pass; - // the tap still works, since a drag consumes the up. - .then(dragModifier) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset) - .semantics { - contentDescription = "$title, $timeLabel" - if (moveAction != null) customActions = listOf(moveAction) - }, - ) { - Column { - if (showTitle) { - BlockTitle( - title = title, - maxLines = titleMaxLines, - color = paint.titleInk, - textDecoration = paint.decoration, - fontWeight = paint.titleWeight, - ) - } - if (showTime) { - BlockTimeLabel( - label = timeLabel, - color = paint.secondaryInk, - maxLines = timeMaxLines, - ) - } - } - } -} - -@Composable -private fun DayLoading() { - val scale = LocalTimelineZoom.current.scale - val scrollState = rememberScrollState() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - // Same scale resolution as the loaded timeline, so the skeleton's column - // doesn't resize the moment the real day arrives. - val hourHeight = scale.hourHeight(maxHeight) - val totalHeight = hourHeight * 24 - // The skeleton wears the loaded column's own ground, so the arrival of - // the real day doesn't flash a solid block into a gapped grid. - val showHourGrid = LocalShowHourGrid.current - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } - Row( - modifier = Modifier - .fillMaxSize() - .padding(end = TIMELINE_CONTENT_END_INSET) - .verticalScroll(scrollState), - ) { - Spacer(Modifier.width(GUTTER_WIDTH)) - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background( - if (showHourGrid) MaterialTheme.colorScheme.surface - else MaterialTheme.colorScheme.surfaceContainer, - ) - .hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer), - ) - } - } -} - -private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = - formatMinuteOfDay(min, is24Hour, locale) - -/** [abbreviated] drops the weekday, leaving the date itself (#165). */ -private fun formatDayTitle( - date: LocalDate, - locale: Locale, - currentYear: Int, - abbreviated: Boolean = false, -): String = - formatCalendarTitle( - date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), - locale = locale, - currentYear = currentYear, - skeleton = if (abbreviated) "dMMM" else "EEEdMMM", - ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt deleted file mode 100644 index 2883366..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt +++ /dev/null @@ -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, - /** Timed events clipped to this day with overlap lanes resolved. */ - val timed: List, - ) : DayUiState -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt deleted file mode 100644 index e310d49..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ /dev/null @@ -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 = _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 = _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( - 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 = 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, - instances: List, - ): 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 { - val from = date.atStartOfDayIn(zone) - val to = date.atTime(23, 59, 59).toInstant(zone) - return from..to -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt deleted file mode 100644 index 90303d9..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewPreview.kt +++ /dev/null @@ -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), - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 2360edd..db61df9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -140,6 +140,7 @@ fun EventDetailScreen( onBack: () -> Unit, onEdit: () -> Unit, onDuplicate: (EventForm) -> Unit, + visible: Boolean = true, viewModel: EventDetailViewModel = hiltViewModel(), ) { LaunchedEffect(eventId, beginMillis, endMillis) { @@ -227,7 +228,7 @@ fun EventDetailScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = onBack), + modifier = Modifier.predictiveBack(onBack = onBack, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index f591797..cc1a8bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -203,6 +203,7 @@ fun EventEditScreen( initialForm: EventForm? = null, initialFormSource: ImportSource = ImportSource.File, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: EventEditViewModel = hiltViewModel(), ) { LaunchedEffect(initialDateIso, editKey, initialForm) { @@ -285,7 +286,7 @@ fun EventEditScreen( } Scaffold( - modifier = Modifier.predictiveBack(onBack = close), + modifier = Modifier.predictiveBack(onBack = close, visible = visible), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index fcf33d3..8205465 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -78,6 +78,7 @@ fun ImportScreen( onOpenSingle: (EventForm) -> Unit, forceMany: Boolean = false, onManageCalendars: (() -> Unit)? = null, + visible: Boolean = true, viewModel: ImportViewModel = hiltViewModel(), ) { // hiltViewModel() resolves to the Activity's store and is retained across @@ -103,7 +104,7 @@ fun ImportScreen( Scaffold( modifier = Modifier - .predictiveBack(onBack = onClose) + .predictiveBack(onBack = onClose, visible = visible) .fillMaxSize(), topBar = { TopAppBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt index da70e2d..8e4674f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/licences/LicencesScreen.kt @@ -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. */ @Composable -internal fun LicencesScreen(onBack: () -> Unit) { +internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) { val uriHandler = LocalUriHandler.current CollapsingScaffold( title = stringResource(R.string.licences_title), onBack = onBack, predictiveBack = true, + visible = visible, ) { Text( stringResource(R.string.licences_intro), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index daeee4f..ec628b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -9,8 +9,20 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.rememberUpdatedState +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight 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.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -62,23 +73,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape 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.filled.Close import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -94,7 +100,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.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_GRACE_MILLIS import de.jeanlucmakiola.calendula.ui.common.EventMoveScope -import de.jeanlucmakiola.calendula.domain.spanFirstDay import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.semantics.customActions @@ -161,11 +165,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -179,7 +179,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -250,20 +249,16 @@ private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): M @OptIn(ExperimentalMaterial3Api::class) @Composable fun MonthScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onOpenWeek: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { + // 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 continuousState by viewModel.continuousState.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() @@ -280,15 +275,16 @@ fun MonthScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val scrolling = viewStyle.isScrolling + var pendingScrollMonth by remember { mutableStateOf(null) } + var scrollRequestId by remember { mutableIntStateOf(0) } // 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 // 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) { rememberPagerState(initialPage = anchorPage) { continuousMonthCount() } } @@ -305,6 +301,15 @@ fun MonthScreen( LaunchedEffect(pagerState) { 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 // 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. LaunchedEffect(listState, scrolling, dense, weekStart) { if (!scrolling) return@LaunchedEffect - snapshotFlow { visibleMonth } + snapshotFlow { visibleMonth to pendingScrollMonth } .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) var splitExpanded by rememberSaveable(viewStyle) { mutableStateOf(viewStyle == MonthViewStyle.Paged) } + var todayPulse by remember { mutableIntStateOf(0) } // 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 @@ -406,15 +417,24 @@ fun MonthScreen( val jumpToMonth: (YearMonth) -> Unit = { ym -> scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) } } - var todayPulse by remember { mutableIntStateOf(0) } val jumpToToday: () -> Unit = { todayPulse++ if (scrolling) { + val targetMonth = YearMonth(today.year, today.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToToday() scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(today, weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToToday() @@ -424,11 +444,21 @@ fun MonthScreen( // Drawer jump-to-date. val jumpToDate: (LocalDate) -> Unit = { target -> if (scrolling) { + val targetMonth = YearMonth(target.year, target.month) + pendingScrollMonth = targetMonth + val requestId = ++scrollRequestId + viewModel.goToDate(target) scope.launch { - listState.animateScrollToItem( - if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) - else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), - ) + try { + listState.animateScrollToItem( + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(targetMonth)), + ) + } finally { + if (scrollRequestId == requestId) { + pendingScrollMonth = null + } + } } } else { viewModel.goToDate(target) @@ -436,141 +466,106 @@ fun MonthScreen( } } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the month swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, - ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - 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, - ) + PublishChrome( + ScreenChrome( + title = topBarTitle, + shortTitle = topBarShortTitle, + titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), + drawerDate = LocalDate(titleMonth.year, titleMonth.month, 1), + awayFromToday = !isOnCurrentMonth, + todayText = stringResource(R.string.month_today_action), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + onCreate = { + // Split has a selected day; the other styles anchor on today when + // its month is shown, else the 1st. + onCreateEvent( + when { + (viewStyle == MonthViewStyle.Split || + (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, ) }, - ) { innerPadding -> - // Hoisted above every style's grid: a dragged chip is drawn in an - // overlay, clear of the week row's and the list viewport's clips. - val chipDrag = rememberMonthDragController() - // Undo moves the event back, so the chip travels back too. The - // signal comes from the write, not from the chip that offers it. - OnUndoStarted { chipDrag.beginUndo() } - Box( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + // Hoisted above every style's grid: a dragged chip is drawn in an + // overlay, clear of the week row's and the list viewport's clips. + val chipDrag = rememberMonthDragController() + // Undo moves the event back, so the chip travels back too. The + // signal comes from the write, not from the chip that offers it. + OnUndoStarted { chipDrag.beginUndo() } + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column(modifier = Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) + CompositionLocalProvider( + LocalDimCutoff provides dimCutoff, + LocalMonthDrag provides chipDrag, + LocalTodayPulse provides todayPulse, + LocalOpenWeek provides onOpenWeek, ) { - Column(modifier = Modifier.fillMaxSize()) { - WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) - CompositionLocalProvider( - LocalDimCutoff provides dimCutoff, - LocalMonthDrag provides chipDrag, - LocalTodayPulse provides todayPulse, - LocalOpenWeek provides onOpenWeek, - ) { - if (scrolling) { - ContinuousMonthContent( - state = continuousState, - listState = listState, - dense = dense, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) { - SplitMonthContent( - state = state, - selected = selectedDate, - initiallyExpanded = viewStyle == MonthViewStyle.Paged, - expanded = splitExpanded, - onExpandedChange = { splitExpanded = it }, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onPaneSwipeNext = { viewModel.stepSplitPane(1) }, - onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, - onClearSelection = viewModel::clearSelection, - onRetry = jumpToToday, - // A tapped leading/trailing day follows to its own - // month, pager and all — straight away, or a swipe - // still settling would land and take the tap back. - onSelectDay = { date -> - viewModel.selectDate(date) - jumpToMonth(YearMonth(date.year, date.month)) - }, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = { onCreateEvent(it, null) }, - ) - } else { - MonthContent( - state = state, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) { + SplitMonthContent( + state = state, + selected = selectedDate, + initiallyExpanded = viewStyle == MonthViewStyle.Paged, + expanded = splitExpanded, + onExpandedChange = { splitExpanded = it }, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onPaneSwipeNext = { viewModel.stepSplitPane(1) }, + onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, + onClearSelection = viewModel::clearSelection, + onRetry = jumpToToday, + // A tapped leading/trailing day follows to its own + // month, pager and all — straight away, or a swipe + // still settling would land and take the tap back. + onSelectDay = { date -> + viewModel.selectDate(date) + jumpToMonth(YearMonth(date.year, date.month)) + }, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) + } else { + MonthContent( + state = state, + pagerState = pagerState, + weekStart = weekStart, + monthState = viewModel::month, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) } } - MonthDragOverlay(chipDrag) - } + } + MonthDragOverlay(chipDrag) } } } @@ -739,7 +734,7 @@ private fun MonthPager( ) { if (static) { Box(modifier) { - MonthPage(pagerState.currentPage, true, weekStart, monthState, loading, page) + MonthPage(pagerState.currentPage, true, true, weekStart, monthState, loading, page) } return } @@ -758,7 +753,15 @@ private fun MonthPager( snapAnimationSpec = rememberCalendarPageSpec(), ), ) { 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( index: Int, active: Boolean, + morphActive: Boolean, weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, loading: @Composable () -> Unit, @@ -775,9 +779,11 @@ private fun MonthPage( val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle() val chipDrag = LocalMonthDrag.current val morph = LocalMonthMorph.current + val viewMorph = LocalViewMorph.current CompositionLocalProvider( LocalMonthDrag provides chipDrag.takeIf { active }, LocalMonthMorph provides morph.takeIf { active }, + LocalViewMorph provides viewMorph.takeIf { morphActive }, ) { when (val s = state) { 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, - 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 internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() @@ -1418,6 +1368,10 @@ private fun SplitMonthContent( // its month still for the length of the cross-fade. val leaving = transition.targetState == EnterExitState.PostExit 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 // travels, and the cross-fade above is the whole transition. LocalMonthMorph provides if (reduceMotion) { @@ -2452,6 +2406,17 @@ private fun MonthWeekRow( 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) .height(EVENT_ROW_HEIGHT) .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), @@ -2509,6 +2474,13 @@ private fun MonthWeekRow( y = EVENT_ROW_HEIGHT * freeSlots[i], ) .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) + .then( + if (blankOutside && !inMonth(d)) { + Modifier + } else { + Modifier.morphEvent(ViewMorphKey.event(ev, d)) + }, + ) .width(colW) .height(EVENT_ROW_HEIGHT) .todayRipple(todayCol?.let { col - it }) { ripple.value } @@ -2780,6 +2752,7 @@ private fun DayNumberCell( if (isToday) { Box( modifier = Modifier + .morphDay(date) .size(DAY_NUMBER_HEIGHT) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center, @@ -2800,6 +2773,7 @@ private fun DayNumberCell( maxLines = 1, overflow = TextOverflow.Visible, softWrap = false, + modifier = Modifier.morphDay(date), ) } else { Text( @@ -2807,6 +2781,7 @@ private fun DayNumberCell( style = MaterialTheme.typography.labelMedium, color = if (inMonth) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.morphDay(date), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index 8f71abc..defdd8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -23,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame import de.jeanlucmakiola.calendula.ui.common.icon 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.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.descriptionRes @@ -73,7 +72,7 @@ internal fun ViewStep( height = PREVIEW_HEIGHT, 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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 2dde1ab..8453905 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -126,6 +126,7 @@ fun SearchScreen( onBack: () -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SearchViewModel = hiltViewModel(), ) { val query by viewModel.query.collectAsStateWithLifecycle() @@ -184,7 +185,7 @@ fun SearchScreen( BackHandler(enabled = inSelection) { viewModel.clearSelection() } Scaffold( - modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), + modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible), containerColor = MaterialTheme.colorScheme.surface, topBar = { SearchTopBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt index 40f1704..5774e5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/AppearanceSettings.kt @@ -76,6 +76,7 @@ internal fun AppearanceScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showTheme by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) } @@ -96,6 +97,7 @@ internal fun AppearanceScreen( title = stringResource(R.string.settings_section_appearance), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Theme & colour GroupedRow( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt index a9674aa..f7d0b39 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/EventFormSettings.kt @@ -41,6 +41,7 @@ internal fun EventFormScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showDefaultDuration by remember { mutableStateOf(false) } // 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), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_form_fields_hint)) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index cc77805..7fe559f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -63,6 +63,7 @@ internal fun NotificationsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, onOpenSpecialDates: () -> Unit, + visible: Boolean = true, ) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( @@ -92,6 +93,7 @@ internal fun NotificationsScreen( title = stringResource(R.string.settings_section_notifications), onBack = onBack, predictiveBack = true, + visible = visible, ) { GroupedRow( title = stringResource(R.string.settings_reminders), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index eddae09..a24d9e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Image import androidx.compose.foundation.background 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.openIssueTracker import de.jeanlucmakiola.floret.crash.submitCrashReport +import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.locale.AppLanguage /** The settings sub-screens reached from the hub's category rows. */ @@ -85,11 +84,14 @@ fun SettingsScreen( onManageCalendars: () -> Unit, onOpenBackup: () -> Unit, modifier: Modifier = Modifier, + visible: Boolean = true, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() var section by rememberSaveable { mutableStateOf(null) } val slideSpec = rememberCalendarSlideSpec() + // Leaves from the predictive-back preview rather than sliding off at full size. + val overlayExit = predictiveBackExit() Box( modifier = modifier @@ -101,61 +103,63 @@ fun SettingsScreen( onOpenSection = { section = it }, onManageCalendars = onManageCalendars, onOpenBackup = onOpenBackup, + visible = visible, ) AnimatedVisibility( visible = section == SettingsSection.Appearance, 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( visible = section == SettingsSection.Views, 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( visible = section == SettingsSection.EventForm, 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( visible = section == SettingsSection.Notifications, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), - exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + exit = overlayExit, ) { NotificationsScreen( state = state, viewModel = viewModel, onBack = { section = null }, onOpenSpecialDates = { section = SettingsSection.SpecialDates }, + visible = section == SettingsSection.Notifications, ) } AnimatedVisibility( visible = section == SettingsSection.SpecialDates, 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( visible = section == SettingsSection.Widgets, 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( visible = section == SettingsSection.Licences, 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, onManageCalendars: () -> 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 // continues the container instead of sitting inside it as a button. Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt index 8933214..0e8559b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SpecialDatesSettings.kt @@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride internal fun SpecialDatesScreen( viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { val state by viewModel.specialDatesState.collectAsStateWithLifecycle() val settings by viewModel.state.collectAsStateWithLifecycle() @@ -76,6 +77,7 @@ internal fun SpecialDatesScreen( title = stringResource(R.string.settings_section_special_dates), onBack = onBack, predictiveBack = true, + visible = visible, ) { // Paused banner: the permission was revoked after enabling. if (state.enabled && state.stalledPermission) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 4f6e859..22ccb1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -68,6 +68,7 @@ internal fun ViewsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showMonthStyle by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } @@ -85,6 +86,7 @@ internal fun ViewsScreen( title = stringResource(R.string.settings_section_views), onBack = onBack, predictiveBack = true, + visible = visible, ) { val config = state.quickSwitchConfig diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 8aa6e34..d8301cc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -42,6 +42,7 @@ internal fun WidgetsScreen( state: SettingsUiState, viewModel: SettingsViewModel, onBack: () -> Unit, + visible: Boolean = true, ) { var showAgendaWidgetRange by remember { mutableStateOf(false) } var showWidgetSize by remember { mutableStateOf(false) } @@ -50,6 +51,7 @@ internal fun WidgetsScreen( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, + visible = visible, ) { SettingsHint(stringResource(R.string.settings_widgets_hint)) Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt new file mode 100644 index 0000000..42a0ed4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometry.kt @@ -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 = 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, toRange: ClosedRange, 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, 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, + val toRange: ClosedRange, + val fromLane: Int, + val toLane: Int, +) + +/** [state]'s all-day bars, standing still. */ +internal fun staticBars(state: WeekUiState.Success): List = + 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 { + 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 = eventId to start.toEpochMilliseconds() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt new file mode 100644 index 0000000..ae52767 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineMorph.kt @@ -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, + val toState: StateFlow, +) { + val progress = Animatable(0f) + + /** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */ + val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count) + + /** Where each day's events come from: the page it was on, else the page it goes to. */ + fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To + + enum class Side { From, To } +} + +private const val LONG_SWITCH_RATIO = 3 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 5c2d92d..e19ed1e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -73,3 +73,35 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid { 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index b2360e0..d717394 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -8,9 +8,16 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerDefaults import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateMapOf +import de.jeanlucmakiola.calendula.ui.common.PublishChrome +import de.jeanlucmakiola.calendula.ui.common.ScreenActions +import de.jeanlucmakiola.calendula.ui.common.ScreenBody +import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec +import de.jeanlucmakiola.calendula.ui.common.rememberViewSwitchSpec import de.jeanlucmakiola.calendula.ui.common.jumpToPage import de.jeanlucmakiola.calendula.ui.common.pagedHeight import kotlinx.coroutines.flow.StateFlow @@ -19,7 +26,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -38,38 +44,42 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.key -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier +import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph +import de.jeanlucmakiola.calendula.ui.common.StayPut +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.first +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.SideEffect +import de.jeanlucmakiola.calendula.ui.common.laneSlot +import de.jeanlucmakiola.calendula.ui.common.LaneColumn +import kotlin.math.roundToInt +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.layout.Layout import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -93,13 +103,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer -import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton -import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn -import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView -import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING @@ -135,15 +140,22 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute -import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus +import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey +import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage +import de.jeanlucmakiola.calendula.ui.common.morphDay +import de.jeanlucmakiola.calendula.ui.common.morphEvent +import de.jeanlucmakiola.calendula.ui.common.rememberTimelineScrollState +import de.jeanlucmakiola.calendula.ui.common.LocalOpeningTimeline +import de.jeanlucmakiola.calendula.ui.common.morphPageActive import de.jeanlucmakiola.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom @@ -155,7 +167,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay -import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @@ -180,6 +191,18 @@ private val HEADER_BOTTOM_PADDING = 8.dp /** The header's date slot, reserved whether or not it holds today's circle. */ private val DATE_SLOT_SIZE = 28.dp +/** A start-aligned header cell's inset, lining its date up with the event titles below. */ +private val HEADER_START_INSET = BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING + +/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */ +@Composable +internal fun weekDayHeaderHeight(): Dp { + val label = MaterialTheme.typography.labelSmall + val line = label.lineHeight.takeIf { it.isSp } ?: label.fontSize + return HEADER_TOP_PADDING + with(LocalDensity.current) { line.toDp() } + 2.dp + + DATE_SLOT_SIZE + HEADER_BOTTOM_PADDING +} + /** Total all-day strip height for a week (0 when there are no all-day events). */ internal fun WeekUiState.Success.allDayStripHeight(): Dp { if (allDaySpans.isEmpty()) return 0.dp @@ -187,100 +210,119 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp { return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 } +/** + * The day, multi-day (#193) and week views: one timeline whose columns resize + * in place when switching between them (#184), rather than three screens. + */ @Composable -fun WeekScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, +fun TimelineScreen( + view: CalendarView, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, - initialDateIso: String? = null, - onInitialDateConsumed: () -> Unit = {}, modifier: Modifier = Modifier, - viewModel: WeekViewModel = hiltViewModel(), + weekViewModel: WeekViewModel = hiltViewModel(), + multiDayViewModel: MultiDayViewModel = hiltViewModel(), + dayViewModel: DayViewModel = hiltViewModel(), ) { - LaunchedEffect(initialDateIso) { - initialDateIso?.let { - viewModel.goToDate(LocalDate.parse(it)) - onInitialDateConsumed() - } + val viewModels = remember(weekViewModel, multiDayViewModel, dayViewModel) { + TimelineViewModels(weekViewModel, multiDayViewModel, dayViewModel) } PagedTimelineScreen( - viewModel = viewModel, - showWeekNumber = true, - selectedView = selectedView, - onSelectView = onSelectView, + view = view, + viewModels = viewModels, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, modifier = modifier, ) } -/** The multi-day view (#193): the week view's timeline over the day count from Settings. */ -@Composable -fun MultiDayScreen( - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, - onOpenDay: (LocalDate) -> Unit, - onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, - onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List = IMPLEMENTED_VIEWS, - drawerViewOrder: List = IMPLEMENTED_VIEWS, - todayInToolbar: Boolean = false, - modifier: Modifier = Modifier, - viewModel: MultiDayViewModel = hiltViewModel(), -) = PagedTimelineScreen( - viewModel = viewModel, +/** Each timeline view's own pages and anchor, held side by side so a switch can show both. */ +internal class TimelineViewModels( + private val week: WeekViewModel, + private val multiDay: MultiDayViewModel, + private val day: DayViewModel, +) { + operator fun get(view: CalendarView): PagedTimelineViewModel = when (view) { + CalendarView.MultiDay -> multiDay + CalendarView.Day -> day + else -> week + } +} + +/** What sets the timeline views apart; everything else they share. */ +internal enum class TimelineKind( + val showWeekNumber: Boolean, + val todayText: Int, + /** Where a header cell sits in its column: centred, or at the start like the event titles. */ + val headerBias: Float, +) { + Week(showWeekNumber = true, todayText = R.string.week_today_action, headerBias = 0f), + // A run of days often straddles two weeks, so no single number fits it. - showWeekNumber = false, - selectedView = selectedView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - modifier = modifier, -) + MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f), + Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f), + ; + + fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String = + if (this == Day) { + formatDayTitle(start, locale, currentYear, abbreviated) + } else { + formatWeekTitle(start, end, locale, currentYear, abbreviated) + } +} + +internal val CalendarView.timelineKind: TimelineKind + get() = when (this) { + CalendarView.MultiDay -> TimelineKind.MultiDay + CalendarView.Day -> TimelineKind.Day + else -> TimelineKind.Week + } + +/** How long a switch waits for the view it goes to before resizing into it without its events. */ +private const val MORPH_DATA_WAIT_MS = 150L @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PagedTimelineScreen( - viewModel: PagedTimelineViewModel, - showWeekNumber: Boolean, - selectedView: CalendarView, - onSelectView: (CalendarView) -> Unit, + view: CalendarView, + viewModels: TimelineViewModels, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, - onOpenSettings: () -> Unit, - onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, - quickSwitchViews: List, - drawerViewOrder: List, - todayInToolbar: Boolean, modifier: Modifier, ) { - val state by viewModel.state.collectAsStateWithLifecycle() - val position by viewModel.position.collectAsStateWithLifecycle() + // The view on screen, which trails [view] while the columns resize into it. + var shown by remember { mutableStateOf(view) } + var morph by remember { mutableStateOf(null) } + // The strip height a switch ended on, for the pager it hands over to. + var handoffStrip by remember { mutableStateOf(null) } + val viewModel = viewModels[shown] + val kind = shown.timelineKind + + // Opens on the focused date, then carries it along as the pages move. + val focus = LocalViewFocus.current + val navigationVersion = focus?.navigationVersion ?: 0 + var handledNavigation by remember { mutableIntStateOf(navigationVersion) } + EnterOnFocus(viewModel) { viewModel.jumpTo(it) } + // Keyed on the view model: collected unkeyed, a switch would read the view + // before's anchor and grid for a frame and open its pager. + val loadedState by key(viewModel) { viewModel.state.collectAsStateWithLifecycle() } + // A view not shown before has nothing loaded yet; the one before it stands + // in, as its pages load their own days anyway, rather than a skeleton. + var lastState by remember { mutableStateOf(WeekUiState.Loading) } + val state = if (loadedState is WeekUiState.Loading) lastState else loadedState + SideEffect { if (loadedState !is WeekUiState.Loading) lastState = loadedState } + LaunchedEffect(shown, loadedState) { + if (loadedState is WeekUiState.Success) handoffStrip = null + } + val anchor by key(viewModel) { viewModel.anchor.collectAsStateWithLifecycle() } + val rule by key(viewModel) { viewModel.rule.collectAsStateWithLifecycle() } + val position = remember(anchor, rule) { positionOf(anchor, rule) } val grid = position.grid val anchorPage = position.page - val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() + val dimCompleted by key(viewModel) { viewModel.dimCompletedEvents.collectAsStateWithLifecycle() } // The instant before which an event counts as completed, or null when dimming // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -289,16 +331,17 @@ private fun PagedTimelineScreen( derivedStateOf { if (dimCompleted) nowState.value else null } } - val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() // A new grid numbers the pages differently, so it gets a pager of its own, - // opened straight on the anchor's page: the first day of the week changing, - // or a multi-day jump moving another date into the first column. - val pagerState = key(grid) { + // opened straight on the anchor's page: another view, the first day of the + // week changing, or a multi-day jump moving another date into the first column. + val pagerState = key(viewModel, grid) { rememberPagerState(initialPage = anchorPage) { grid.pageCount } } val pageSpec = rememberCalendarPageSpec() + val switchSpec = rememberViewSwitchSpec() + val longSwitchSpec = rememberViewSwitchSpec(long = true) val reduceMotion = rememberReduceMotion() // The pager leads and the anchor follows once it settles, so this only // catches the anchor moving on its own. Snapped, since that is a correction @@ -311,11 +354,65 @@ private fun PagedTimelineScreen( // The page it opened on is skipped: a grid change landing in between would // read it as a different page. LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled) + snapshotFlow { pagerState.settledPage }.drop(1).collect { page -> + viewModel.onPageSettled(page) + focus?.let { it.date = grid.focusOnPage(it.date, page) } + } } - // The page under the finger, so the title turns over mid-swipe, not after it. - val weekStart = grid.startForPage(pagerState.currentPage) - val weekEnd = weekStart.plus(grid.dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY) + + // Switching to another timeline view resizes the columns into it (#184): + // from the page on screen to the target view's page around the focused + // date, then hands over to that view's pager at the page the resize ended on. + val currentShown by rememberUpdatedState(shown) + val currentPager by rememberUpdatedState(pagerState) + val currentGrid by rememberUpdatedState(grid) + val currentState by rememberUpdatedState(state) + LaunchedEffect(view, navigationVersion) { + if (navigationVersion != handledNavigation) { + handledNavigation = navigationVersion + morph = null + handoffStrip = null + viewModels[view].jumpTo(focus?.date ?: return@LaunchedEffect) + shown = view + return@LaunchedEffect + } + // A switch interrupted by another lands first, and this one starts from there. + snapshotFlow { morph }.first { it == null } + withFrameNanos { } + if (view == currentShown) return@LaunchedEffect + val from = currentShown + val fromGrid = currentGrid + val fromSpan = DaySpan(fromGrid.startForPage(currentPager.currentPage), fromGrid.dayCount) + val target = (focus?.date ?: fromSpan.start).coerceIn(fromSpan.start, fromSpan.end) + focus?.date = target + val toModel = viewModels[view] + toModel.jumpTo(target) + val to = positionOf(target, toModel.rule.value) + val toSpan = DaySpan(to.grid.startForPage(to.page), to.grid.dayCount) + val fromLoaded = viewModels[from].span(fromSpan) + val toLoaded = toModel.span(toSpan) + if (reduceMotion || currentState !is WeekUiState.Success) { + shown = view + return@LaunchedEffect + } + val switch = TimelineMorph(from.timelineKind, view.timelineKind, fromSpan, toSpan, fromLoaded, toLoaded) + try { + withTimeoutOrNull(MORPH_DATA_WAIT_MS) { toLoaded.first { it !is WeekUiState.Loading } } + morph = switch + switch.progress.animateTo(1f, if (switch.isLong) longSwitchSpec else switchSpec) + handoffStrip = (toLoaded.value as? WeekUiState.Success)?.allDayStripHeight() + shown = view + } finally { + morph = null + } + } + + // The page under the finger, so the title turns over mid-swipe, not after + // it; mid-switch, the page being switched to. + val weekStart = morph?.to?.start ?: grid.startForPage(pagerState.currentPage) + val titleKind = morph?.toKind ?: kind + val dayCount = morph?.to?.count ?: grid.dayCount + val weekEnd = weekStart.plus(dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY) // Off the ticking clock rather than the loaded page, so it turns over at // midnight; derived, so the per-minute tick doesn't recompose the screen. @@ -327,6 +424,7 @@ private fun PagedTimelineScreen( // hasn't caught up with the fling yet to tell the difference. A date that // would open mid-page on this grid re-frames the pages around it instead. val jumpToDate: (LocalDate) -> Unit = { target -> + focus?.date = target val page = grid.pageFor(target) if (grid is DaysGrid && grid.startForPage(page) != target) { viewModel.jumpTo(target) @@ -336,76 +434,46 @@ private fun PagedTimelineScreen( } val jumpToToday = { jumpToDate(today) } - ModalNavigationDrawer( - drawerState = drawerState, - // Open only via the menu button — edge-swipe would fight the week swipe. - gesturesEnabled = drawerState.isOpen, - drawerContent = { - CalendarDrawer( - currentView = selectedView, - currentDate = weekStart, - drawerState = drawerState, - viewOrder = drawerViewOrder, - onSelectView = { view -> - onSelectView(view) - scope.launch { drawerState.close() } - }, - onJumpToDate = { target -> - jumpToDate(target) - scope.launch { drawerState.close() } - }, - onSettings = { - onOpenSettings() - scope.launch { drawerState.close() } - }, + val locale = currentLocale() + val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) { + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to + titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true) + } + PublishChrome( + ScreenChrome( + title = title, + shortTitle = shortTitle, + titleDate = weekStart, + drawerDate = weekStart, + awayFromToday = !isOnCurrentWeek, + todayText = stringResource(titleKind.todayText), + ), + ScreenActions( + onToday = jumpToToday, + onJumpToDate = jumpToDate, + // Anchor on today when it's in view, else the page's first day. + onCreate = { onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) }, + ), + ) + + ScreenBody(modifier = modifier) { innerPadding -> + CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { + WeekContent( + state = state, + pagerState = pagerState, + grid = grid, + kind = kind, + morph = morph, + handoffStrip = handoffStrip, + span = viewModel::span, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), ) - }, - ) { - Scaffold( - modifier = modifier, - topBar = { - WeekTopBar( - weekStart = weekStart, - weekEnd = weekEnd, - currentYear = today.year, - 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 = !isOnCurrentWeek && !todayInToolbar, - todayText = stringResource(R.string.week_today_action), - onToday = jumpToToday, - onCreate = { - // Anchor on today when it's in view, else the week's first day. - onCreateEvent(if (isOnCurrentWeek) today else weekStart, null) - }, - ) - }, - ) { innerPadding -> - CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - WeekContent( - state = state, - pagerState = pagerState, - grid = grid, - showWeekNumber = showWeekNumber, - span = viewModel::span, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = { d, minutes -> onCreateEvent(d, minutes) }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - } } } } @@ -415,7 +483,9 @@ private fun WeekContent( state: WeekUiState, pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, + morph: TimelineMorph?, + handoffStrip: Dp?, span: (DaySpan) -> StateFlow, onRetry: () -> Unit, onEventClick: (EventInstance) -> Unit, @@ -426,11 +496,11 @@ private fun WeekContent( val fadeSpec = rememberCalendarFadeSpec() // Shared by every page and the gutter, so the vertical scroll position - // survives week-to-week swipes (e.g. 18:00 stays centred). Positioned once - // on entry into the week view, not on every swipe: at the visible hours' - // start if set (#322), otherwise noon. - val scrollState = rememberScrollState() - OpenTimelineScroll(scrollState, LocalTimelineZoom.current) + // survives week-to-week swipes (e.g. 18:00 stays centred), and carried over + // from the timeline view before this one. + val stripHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp + val timelineScroll = rememberTimelineScrollState(weekDayHeaderHeight() + stripHeight + TIMELINE_TOP_GAP) + val scrollState = timelineScroll.state // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. @@ -449,32 +519,45 @@ private fun WeekContent( } Box(modifier = modifier) { - // Loading and failure concern the provider as a whole, so they stand in - // for the pager; a page still loading its own week handles that itself. - AnimatedContent( - targetState = state, - modifier = Modifier.fillMaxSize(), - contentKey = { it::class }, - transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, - label = "week-state", - ) { s -> - when (s) { - WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) - is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) - is WeekUiState.Success -> WeekPager( - pagerState = pagerState, - grid = grid, - showWeekNumber = showWeekNumber, - span = span, - today = s.today, - initialStripHeight = s.allDayStripHeight(), - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) + CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) { + // Loading and failure concern the provider as a whole, so they stand in + // for the pager; a page still loading its own week handles that itself. + AnimatedContent( + targetState = state, + modifier = Modifier.fillMaxSize(), + contentKey = { it::class }, + transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) }, + label = "week-state", + ) { s -> + when (s) { + WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount) + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> if (morph != null) { + TimelineMorphFrame( + morph = morph, + today = s.today, + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } else WeekPager( + pagerState = pagerState, + grid = grid, + kind = kind, + span = span, + today = s.today, + initialStripHeight = handoffStrip ?: s.allDayStripHeight(), + scrollState = scrollState, + dragController = dragController, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } } } TimelineDragOverlay(dragController) @@ -489,7 +572,7 @@ private fun WeekContent( private fun WeekPager( pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, initialStripHeight: Dp, @@ -504,7 +587,7 @@ private fun WeekPager( val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight) WeekFrame( weekStart = grid.startForPage(pagerState.currentPage), - showWeekNumber = showWeekNumber, + showWeekNumber = kind.showWeekNumber, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -532,17 +615,20 @@ private fun WeekPager( if (stripHeight != null) stripHeights[page] = stripHeight onDispose { stripHeights.remove(page) } } - WeekPage( - state = loaded ?: empty, - allDayHeight = allDayHeight, - scrollState = scrollState, - dragController = dragController, - active = page == pagerState.currentPage, - onEventClick = onEventClick, - onOpenDay = onOpenDay, - onCreateAt = onCreateAt, - onDrop = onDrop, - ) + ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { + WeekPage( + state = loaded ?: empty, + headerBias = kind.headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = page == pagerState.currentPage, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } } } } @@ -559,6 +645,99 @@ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { ) } +/** + * The timeline mid-switch between two views (#184): every day of both pages, + * each column between its place on the one and on the other, with no pager + * under them. It starts and ends exactly as the two pages lay out, so the + * pagers either side hand over without a jump. + */ +@Composable +private fun TimelineMorphFrame( + morph: TimelineMorph, + today: LocalDate, + scrollState: ScrollState, + dragController: TimelineDragController, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, +) { + val fromState by morph.fromState.collectAsStateWithLifecycle() + val toState by morph.toState.collectAsStateWithLifecycle() + val from = fromState as? WeekUiState.Success + val to = toState as? WeekUiState.Success + val geometry = remember(morph) { ColumnGeometry(morph.from, morph.to) { morph.progress.value } } + val bars = remember(from, to) { morphBars(from, to) } + val timedFor: (LocalDate) -> List = remember(morph, from, to) { + { date -> + val side = if (morph.sourceOf(date) == TimelineMorph.Side.From) from else to + side?.timedByDay?.get(date).orEmpty() + } + } + val fromStrip = from?.allDayStripHeight() ?: 0.dp + val toStrip = to?.allDayStripHeight() ?: fromStrip + val animatedStrip = remember(morph, fromStrip, toStrip) { + { androidx.compose.ui.unit.lerp(fromStrip, toStrip, morph.progress.value.coerceIn(0f, 1f)) } + } + val fromBadge = if (morph.fromKind.showWeekNumber) 1f else 0f + val toBadge = if (morph.toKind.showWeekNumber) 1f else 0f + val badgeAlpha = remember(morph) { + { lerp(fromBadge, toBadge, morph.progress.value).coerceIn(0f, 1f) } + } + // The header changes its alignment once; its columns and bars move in layout. + val headerBias by remember(morph) { + derivedStateOf { + if (morph.progress.value < 0.5f) morph.fromKind.headerBias else morph.toKind.headerBias + } + } + // The events travel inside the columns here, not between views. + CompositionLocalProvider(LocalViewMorph provides null) { + WeekFrame( + weekStart = if (morph.toKind.showWeekNumber) morph.to.start else morph.from.start, + showWeekNumber = morph.fromKind.showWeekNumber || morph.toKind.showWeekNumber, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + weekNumberAlpha = badgeAlpha, + scrollState = scrollState, + dragController = dragController, + ) { + TimelinePageContent( + geometry = geometry, + today = today, + timedFor = timedFor, + bars = bars, + headerBias = headerBias, + allDayHeight = fromStrip, + animatedAllDayHeight = animatedStrip, + scrollState = scrollState, + dragController = dragController, + active = false, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) + } + } +} + +/** Read an animated strip height during measurement, without composing its contents again. */ +private fun Modifier.dynamicHeight(height: () -> Dp): Modifier = layout { measurable, constraints -> + val pixels = height().roundToPx().coerceIn(constraints.minHeight, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = pixels, maxHeight = pixels)) + layout(placeable.width, pixels) { placeable.place(0, 0) } +} + +/** Keep a morph's expensive viewport content measured at one size while its visible edge moves. */ +private fun Modifier.stableMorphViewport(stripHeight: () -> Dp, fullHeight: IntArray): Modifier = + clipToBounds().layout { measurable, constraints -> + if (fullHeight[0] < 0) fullHeight[0] = constraints.maxHeight + stripHeight().roundToPx() + val measured = measurable.measure( + constraints.copy(minHeight = fullHeight[0], maxHeight = fullHeight[0]), + ) + layout(constraints.maxWidth, constraints.maxHeight) { measured.place(0, 0) } + } + /** * What stays put while the weeks swipe: the week-number badge and the hour * gutter, with [pages] filling the rest. @@ -568,10 +747,13 @@ private fun WeekFrame( weekStart: LocalDate, showWeekNumber: Boolean, allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, + weekNumberAlpha: () -> Float = { 1f }, scrollState: ScrollState, dragController: TimelineDragController, pages: @Composable () -> Unit, ) { + val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) } Row(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) { Column( @@ -579,24 +761,38 @@ private fun WeekFrame( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekNumberCell(weekStart, showBadge = showWeekNumber) - Spacer(Modifier.height(allDayHeight)) + WeekNumberCell( + weekStart = weekStart, + showBadge = showWeekNumber, + modifier = if (animatedAllDayHeight == null) Modifier + else Modifier.graphicsLayer { alpha = weekNumberAlpha() }, + ) + Spacer(if (animatedAllDayHeight == null) Modifier.height(allDayHeight) + else Modifier.dynamicHeight(animatedAllDayHeight)) } Spacer(Modifier.height(TIMELINE_TOP_GAP)) // Resolves the hour height off the same viewport height the pages' // timelines have, so labels and columns agree. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, gutterFullHeight), + ), + ) { val zoom = LocalTimelineZoom.current - val hourHeight = zoom.scale.hourHeight(maxHeight) + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight + else maxHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = scrollState, hourHeight = hourHeight, dragController = dragController, - modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom), + modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom), ) } } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() } + // Clipped, as the pager was: a view switch slides columns in past its edges. + Box(modifier = Modifier.weight(1f).fillMaxHeight().clipToBounds()) { pages() } } } @@ -605,6 +801,7 @@ private fun WeekFrame( internal fun WeekSuccess( state: WeekUiState.Success, showWeekNumber: Boolean = true, + headerBias: Float = 0f, scrollState: ScrollState, allDayHeight: Dp, dragController: TimelineDragController, @@ -622,6 +819,7 @@ internal fun WeekSuccess( ) { WeekPage( state = state, + headerBias = headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -643,6 +841,7 @@ internal fun WeekSuccess( @Composable private fun WeekPage( state: WeekUiState.Success, + headerBias: Float, allDayHeight: Dp, scrollState: ScrollState, dragController: TimelineDragController, @@ -651,6 +850,48 @@ private fun WeekPage( onOpenDay: (LocalDate) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, +) { + val geometry = remember(state.weekStart, state.days.size) { + ColumnGeometry.of(DaySpan(state.weekStart, state.days.size)) + } + val bars = remember(state) { staticBars(state) } + TimelinePageContent( + geometry = geometry, + today = state.today, + timedFor = { state.timedByDay[it].orEmpty() }, + bars = bars, + headerBias = headerBias, + allDayHeight = allDayHeight, + scrollState = scrollState, + dragController = dragController, + active = active, + onEventClick = onEventClick, + onOpenDay = onOpenDay, + onCreateAt = onCreateAt, + onDrop = onDrop, + ) +} + +/** + * A page's header, all-day strip and day columns, laid out over [geometry]: a + * single page's, or both pages' of a view switch mid-way (#184). + */ +@Composable +private fun TimelinePageContent( + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, + bars: List, + headerBias: Float, + allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, + scrollState: ScrollState, + dragController: TimelineDragController, + active: Boolean, + onEventClick: (EventInstance) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onCreateAt: (LocalDate, Int) -> Unit, + onDrop: (TimelineDrop) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { Column( @@ -658,18 +899,23 @@ private fun WeekPage( .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) { - WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) + WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay) // This strip is painted on `surface`, so an outlined chip has to // fill with that and not with the columns' container (#230). CompositionLocalProvider( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { - AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) + AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, + animatedHeight = animatedAllDayHeight, onEventClick = onEventClick) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) Timeline( - state = state, + geometry = geometry, + today = today, + timedFor = timedFor, + allDayHeight = allDayHeight, + animatedAllDayHeight = animatedAllDayHeight, scrollState = scrollState, dragController = dragController, active = active, @@ -680,125 +926,66 @@ private fun WeekPage( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun WeekTopBar( - weekStart: LocalDate, - weekEnd: LocalDate, - currentYear: Int, - selectedView: CalendarView, - onCycleView: () -> Unit, - quickSwitchViews: List, - onOpenDrawer: () -> Unit, - onOpenSearch: () -> Unit, - onJumpToDate: (LocalDate) -> Unit, - showTodayButton: Boolean, - onToday: () -> Unit, -) { - val locale = currentLocale() - val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) { - formatWeekTitle(weekStart, weekEnd, locale, currentYear) to - formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true) - } - TopAppBar( - title = { - CalendarTitleButton( - title = title, - currentDate = weekStart, - 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 private fun WeekDayHeader( - days: List, + geometry: ColumnGeometry, today: LocalDate, + bias: Float, onOpenDay: (LocalDate) -> Unit, ) { val locale = currentLocale() - Row( + TimelineColumns( + geometry = geometry, + gap = 0.dp, modifier = Modifier .fillMaxWidth() .padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET), - ) { - days.forEach { date -> - val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) - val isToday = date == today - Column( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(12.dp)) - .clickable { onOpenDay(date) }, - horizontalAlignment = Alignment.CenterHorizontally, + ) { date -> + val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) + val isToday = date == today + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onOpenDay(date) } + .padding(start = HEADER_START_INSET * -bias), + horizontalAlignment = BiasAlignment.Horizontal(bias), + ) { + // One line, as the gutter cell beside it assumes (see WeekNumberCell). + Text( + text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + // Always reserve the circle slot so the header height is + // identical whether or not the week contains today. + Box( + modifier = Modifier.size(DATE_SLOT_SIZE), + contentAlignment = Alignment.Center, ) { - // One line, as the gutter cell beside it assumes (see WeekNumberCell). - Text( - text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), - style = MaterialTheme.typography.labelSmall, - maxLines = 1, - overflow = TextOverflow.Clip, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(2.dp)) - // Always reserve the circle slot so the header height is - // identical whether or not the week contains today. - Box( - modifier = Modifier.size(DATE_SLOT_SIZE), - contentAlignment = Alignment.Center, - ) { - if (isToday) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.fillMaxSize(), - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - } + if (isToday) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.morphDay(date).fillMaxSize(), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) } - } else { - Text( - text = date.day.toString(), - style = MaterialTheme.typography.bodyMedium, - ) } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.morphDay(date), + ) } } } @@ -812,10 +999,10 @@ private fun WeekDayHeader( * bar's hamburger (see GUTTER_CONTENT_START_INSET). */ @Composable -private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean) { +private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) { val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding( start = GUTTER_CONTENT_START_INSET, @@ -854,8 +1041,10 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { @Composable private fun AllDayStrip( - state: WeekUiState.Success, + geometry: ColumnGeometry, + bars: List, height: Dp, + animatedHeight: (() -> Dp)? = null, onEventClick: (EventInstance) -> Unit, ) { val dark = isSystemInDarkTheme() @@ -865,7 +1054,7 @@ private fun AllDayStrip( .fillMaxWidth() // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. - .height(height) + .then(if (animatedHeight == null) Modifier.height(height) else Modifier.dynamicHeight(animatedHeight)) .padding( top = ALL_DAY_VERTICAL_PADDING, bottom = ALL_DAY_VERTICAL_PADDING, @@ -875,28 +1064,39 @@ private fun AllDayStrip( // Span bars are positioned absolutely so a multi-day event is one // connected bar across columns rather than a chip per day. clipToBounds // keeps bars from spilling out while the height animates. - BoxWithConstraints( + Layout( + content = { + bars.forEach { bar -> + key(bar.event.eventId, bar.event.start) { + AllDayBar( + event = bar.event, + dark = dark, + onClick = { onEventClick(bar.event) }, + modifier = Modifier + .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) + .padding(horizontal = 1.dp, vertical = 1.dp), + ) + } + } + }, modifier = Modifier .weight(1f) .fillMaxHeight() .clipToBounds(), - ) { - val colWidth = maxWidth / state.days.size - state.allDaySpans.forEach { span -> - val spanCols = span.endCol - span.startCol + 1 - AllDayBar( - event = span.event, - dark = dark, - onClick = { onEventClick(span.event) }, - modifier = Modifier - .offset( - x = colWidth * span.startCol, - y = ALL_DAY_ROW_HEIGHT * span.lane, - ) - .width(colWidth * spanCols) - .height(ALL_DAY_ROW_HEIGHT) - .padding(horizontal = 1.dp, vertical = 1.dp), - ) + ) { measurables, constraints -> + val width = constraints.maxWidth.toFloat() + val rowPx = ALL_DAY_ROW_HEIGHT.toPx() + val progress = geometry.progress() + val placed = measurables.mapIndexed { i, measurable -> + val bar = bars[i] + val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) + val left = extent.left.roundToInt() + val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) + val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() + Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) + } + layout(constraints.maxWidth, constraints.maxHeight) { + placed.forEach { (placeable, x, y) -> placeable.placeRelative(x, y) } } } } @@ -937,7 +1137,11 @@ private fun AllDayBar( @Composable private fun Timeline( - state: WeekUiState.Success, + geometry: ColumnGeometry, + today: LocalDate, + timedFor: (LocalDate) -> List, + allDayHeight: Dp, + animatedAllDayHeight: (() -> Dp)? = null, scrollState: ScrollState, dragController: TimelineDragController, active: Boolean, @@ -945,6 +1149,7 @@ private fun Timeline( onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, ) { + val fullViewportHeight = remember(geometry) { intArrayOf(-1) } val dark = isSystemInDarkTheme() val zoom = LocalTimelineZoom.current val density = LocalDensity.current @@ -958,14 +1163,38 @@ private fun Timeline( // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // timeline's own viewport height, which is only known here — below the top - // bar, day header and all-day strip. - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val hourHeight = zoom.scale.hourHeight(maxHeight) + // bar, day header and all-day strip. The strip is given back, so the scale + // holds while paging and matches the day view's (#184). + BoxWithConstraints( + modifier = Modifier.fillMaxSize().then( + if (animatedAllDayHeight == null) Modifier + else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), + ), + ) { + val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight + val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the // vertical scroll, and it does that by watching the initial pass, which // only reaches it if it is the viewport's ancestor. - val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom) + val pinch = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom) + // What a block's text is fitted to: the column it will end up in once a + // view switch is past half way, so it reflows once rather than per frame. + val textColumns by remember(geometry) { + derivedStateOf { if (geometry.progress() >= 0.5f) geometry.to.count else geometry.from.count } + } + val columnWidth = (maxWidth - TIMELINE_CONTENT_END_INSET + COLUMN_GAP) / textColumns - COLUMN_GAP + // What the scroll shows, known before it is laid out: a view switch + // picks what travels on its first frame. + val opening = LocalOpeningTimeline.current + val viewportPx = with(density) { maxHeight.toPx() } + val visibleViewport: () -> Float = remember(animatedAllDayHeight, viewportPx, density) { + { viewportPx - (animatedAllDayHeight?.let { with(density) { it().toPx() } } ?: 0f) } + } + val contentPx = with(density) { totalHeight.toPx() } + val visibleTop: () -> Float = remember(opening, scrollState, visibleViewport, contentPx) { + { opening?.top(visibleViewport(), contentPx) ?: scrollState.value.toFloat() } + } // Scrolls on the same state as the gutter and the other pages, so they // all stay aligned. A static, rounded-clipped window — the content // scrolls inside it, so the soft corners are permanent at any scroll @@ -979,7 +1208,9 @@ private fun Timeline( .verticalScroll(scrollState) .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, ) { - Row( + TimelineColumns( + geometry = geometry, + gap = COLUMN_GAP, modifier = Modifier .fillMaxWidth() .height(totalHeight) @@ -995,30 +1226,28 @@ private fun Timeline( it.hourPx = with(density) { hourHeight.toPx() } it.blockInsetPx = blockInsetPx it.columnGapPx = gap - it.columnWidthPx = (coords.size.width + gap) / state.days.size - it.days = state.days + it.columnWidthPx = (coords.size.width + gap) / geometry.to.count + it.days = geometry.dates it.isRtl = isRtl } }, - horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP), - ) { - state.days.forEach { day -> - DayColumnCard( - blocks = state.timedByDay[day].orEmpty(), - dark = dark, - date = day, - today = state.today, - hourHeight = hourHeight, - scrollState = scrollState, - dragController = dragController, - onEventClick = onEventClick, - onCreateAt = onCreateAt, - onDrop = onDrop, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - } + ) { day -> + DayColumnCard( + blocks = timedFor(day), + dark = dark, + date = day, + today = today, + hourHeight = hourHeight, + columnWidth = columnWidth, + visibleTop = visibleTop, + viewportHeight = visibleViewport, + dragController = dragController, + onEventClick = onEventClick, + onCreateAt = onCreateAt, + onDrop = onDrop, + scrollState = scrollState, + modifier = Modifier.fillMaxHeight(), + ) } } } @@ -1031,14 +1260,19 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, - scrollState: ScrollState, + columnWidth: Dp, + visibleTop: () -> Float, + viewportHeight: () -> Float, dragController: TimelineDragController, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, + scrollState: ScrollState, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { hourHeight.toPx() } + val density = LocalDensity.current + val hourPx = with(density) { hourHeight.toPx() } + val morphing = LocalViewMorph.current != null val showHourGrid = LocalShowHourGrid.current val zone = remember { TimeZone.currentSystemDefault() } // Tells a settled drop when this column has caught up with it. @@ -1070,7 +1304,8 @@ private fun DayColumnCard( ), modifier = modifier, ) { - BoxWithConstraints( + Box(Modifier.fillMaxSize()) { + LaneColumn( modifier = Modifier .fillMaxSize() // The hour cells sit over the column background but under the @@ -1088,7 +1323,6 @@ private fun DayColumnCard( } }, ) { - val colWidth = maxWidth val minEventHeight = hourHeight * MIN_EVENT_FRACTION // Keyed by event, so a block that changes time or lane is the *same* // composable afterwards and tweens there. The ordinal disambiguates @@ -1097,14 +1331,13 @@ private fun DayColumnCard( blocks.forEach { block -> val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 key(block.event.eventId, ordinal) { - val laneWidth = colWidth / block.laneCount val top = hourHeight * (block.startMin / 60f) val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight val place = animatedBlockPlacement( - x = laneWidth * block.lane, + xFraction = block.lane.toFloat() / block.laneCount, + widthFraction = 1f / block.laneCount, y = top, - width = laneWidth, height = height, ) // Where this block is cut at midnight, which decides both @@ -1119,11 +1352,22 @@ private fun DayColumnCard( val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) .coerceAtLeast(0.dp) + // Only a block wholly on screen travels in a view switch: the + // rest would cross the header on their way. They fade with + // their screen, and the other view leaves them be too. + val travels = morphing && rememberInViewport( + visibleTop = visibleTop, + viewportHeight = viewportHeight, + top = with(density) { (top + topInset).toPx() }, + bottom = with(density) { (top + height - bottomInset).toPx() }, + ) + val morphKey = ViewMorphKey.event(block.event, date) + StayPut(morphKey, stays = morphing && !travels) EventBlock( block = block, dark = dark, height = blockHeight, - width = place.width, + width = columnWidth / block.laneCount, date = date, cuts = cuts, topInset = topInset, @@ -1131,8 +1375,9 @@ private fun DayColumnCard( onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .width(place.width) + .laneSlot(place) + .offset(y = place.y + topInset) + .then(if (travels) Modifier.morphEvent(morphKey) else Modifier) .height(blockHeight) .padding(horizontal = BLOCK_OUTER_INSET), ) @@ -1142,6 +1387,7 @@ private fun DayColumnCard( if (date == today) { NowLine(date = date, hourHeight = hourHeight) } + } OutOfViewEdgeBars( edges = edges, scrollState = scrollState, @@ -1151,6 +1397,15 @@ private fun DayColumnCard( } } +/** Whether [top]..[bottom], in pixels down the scrolled content, lies wholly in its viewport. */ +@Composable +private fun rememberInViewport(visibleTop: () -> Float, viewportHeight: () -> Float, top: Float, bottom: Float): Boolean { + val inView by remember(visibleTop, viewportHeight, top, bottom) { + derivedStateOf { visibleTop().let { top >= it && bottom <= it + viewportHeight() } } + } + return inView +} + @Composable private fun EventBlock( block: TimedBlock, @@ -1274,20 +1529,24 @@ private fun EventBlock( } } +private val SKELETON_HEADER_HEIGHT = 36.dp +private val SKELETON_HEADER_PADDING = 8.dp + @Composable private fun WeekLoading(dayCount: Int) { val scale = LocalTimelineZoom.current.scale val scrollState = rememberScrollState() + val headerHeight = weekDayHeaderHeight() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton - Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row(modifier = Modifier.fillMaxWidth().padding(SKELETON_HEADER_PADDING)) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(dayCount) { Box( modifier = Modifier .weight(1f) .padding(horizontal = 2.dp) - .height(36.dp) + .height(SKELETON_HEADER_HEIGHT) .background( MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(8.dp), @@ -1298,7 +1557,9 @@ private fun WeekLoading(dayCount: Int) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val hourHeight = scale.hourHeight(maxHeight) + val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP + val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival // of the real week doesn't flash solid blocks into a gapped grid. @@ -1366,3 +1627,17 @@ private fun formatWeekTitle( forceYear = weekEnd.year != weekStart.year, ) } + +/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */ +private fun formatDayTitle( + date: LocalDate, + locale: Locale, + currentYear: Int, + abbreviated: Boolean = false, +): String = + formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), + locale = locale, + currentYear = currentYear, + skeleton = if (abbreviated) "dMMM" else "EEEdMMM", + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index ae2e570..e226fe6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map 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. */ 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). * 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 // settings change re-frames the same days instead of jumping. private val _anchor = MutableStateFlow(todayDate) + val anchor: StateFlow = _anchor - val position: StateFlow = - 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 = + combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) } .distinctUntilChanged() .stateIn( scope = viewModelScope, 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. */ 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) } @@ -209,6 +222,20 @@ class MultiDayViewModel @Inject constructor( 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. * Each event becomes one [AllDaySpan] from its first to its last covered column; diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index 8a7feee..d336cdf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -50,6 +50,7 @@ internal fun WeekViewPreview( WeekSuccess( state = state, showWeekNumber = dayCount == 7, + headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f, scrollState = scrollState, allDayHeight = state.allDayStripHeight(), dragController = rememberTimelineDragController(), diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt new file mode 100644 index 0000000..4e5c7b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaItemDatesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt new file mode 100644 index 0000000..b0c5a29 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewMorphKeyTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt new file mode 100644 index 0000000..538c7da --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewScreenTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt new file mode 100644 index 0000000..9dd729c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/ColumnGeometryTest.kt @@ -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): 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, + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt new file mode 100644 index 0000000..96e5025 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/FocusOnPageTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt index 2ac6c63..0480c9f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekPagingTest.kt @@ -64,4 +64,18 @@ class WeekPagingTest { assertThat(grid.startForPage(page)).isEqualTo(date) 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)) + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed3afde..f444241 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery). ```mermaid flowchart TD 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 subgraph Data ["data/"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] @@ -79,10 +79,42 @@ flowchart TD There is no navigation library. `MainActivity` hosts `RootScreen`, which gates on the first-launch wizard (`ui/onboarding/`), then shows -`CalendarHost`. `CalendarHost` holds the active view (month/week/day) -plus overlay state for detail, edit, and settings — full-screen overlays +`CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week, +agenda) plus overlay state for detail, edit, and settings — full-screen overlays driven by `AnimatedVisibility` with a *held-key* pattern: the last shown 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` + `onNewIntent`) as an external detail key that `CalendarHost` consumes exactly like an event tap. diff --git a/floret-kit b/floret-kit index 14c13ba..b7e73ee 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 14c13bae6d846845791e8b32eca6d016d2524364 +Subproject commit b7e73ee880449ade8d6162b56654083887fe669b