diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9c46b9..20fd1a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]). +- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]). + +### Changed +- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]). +- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]). + +### Fixed +- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]). + ## [2.21.0] — 2026-09-23 ### Added diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 8801b445..347b9101 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -170,6 +170,7 @@ class MainActivity : AppCompatActivity() { // weeks and switching between the week and day view. val timelineZoom = rememberTimelineZoom( stored = settings.timelineScale, + hours = settings.timelineHours, onPersist = settingsViewModel::setTimelineScale, ) val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() 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 c911efac..bc098c9e 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 @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.storageValue @@ -296,6 +297,27 @@ class SettingsPrefs @Inject constructor( 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. + */ + val timelineHours: Flow = store.data.map { prefs -> + TimelineHours.of( + enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false, + startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR, + endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR, + ) + } + + suspend fun setTimelineHours(hours: TimelineHours) { + val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour) + store.edit { + it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled + it[TIMELINE_HOURS_START_KEY] = valid.startHour + it[TIMELINE_HOURS_END_KEY] = valid.endHour + } + } + /** * 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 "+" @@ -1037,6 +1059,9 @@ class SettingsPrefs @Inject constructor( private const val MINUTES_PER_DAY = 1_440 internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count") + internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled") + internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start") + internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end") /** Default snooze delay for the notification "Snooze" action. */ const val DEFAULT_SNOOZE_MINUTES = 10 const val DEFAULT_MULTI_DAY_COUNT = 3 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 17d72131..7ca24b74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor( is24Hour = is24Hour, today = Instant.now().atZone(zone).toLocalDate(), firstDayOfWeek = firstDayOfWeek, + todayLabel = context.getString(R.string.reminder_day_today), tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), yesterdayLabel = context.getString(R.string.reminder_day_yesterday), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index cdc7462e..c169d709 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -27,12 +27,12 @@ import java.util.Locale * alone would be ambiguous) * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time, * already unambiguous) - * - all-day, one day: "11 Jun 2026" - * - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026" + * - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules, + * plus [todayLabel] — no time range to lean on) + * - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end) * - * All-day instances already carry an explicit date, so they never gain a - * relative prefix. They store UTC midnights with an exclusive end, so they are - * read in UTC and the end day is the last *covered* day. + * All-day instances store UTC midnights with an exclusive end, so they are read + * in UTC and the end day is the last *covered* day (issue #308). */ fun reminderTimeText( beginMillis: Long, @@ -43,6 +43,7 @@ fun reminderTimeText( is24Hour: Boolean, today: LocalDate, firstDayOfWeek: DayOfWeek, + todayLabel: String, tomorrowLabel: String, yesterdayLabel: String, ): String { @@ -53,11 +54,16 @@ fun reminderTimeText( val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() .minusDays(1) .coerceAtLeast(firstDay) - return if (lastDay == firstDay) { - dateFormat.format(firstDay) - } else { - dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay) - } + val start = relativeDayPrefix( + day = firstDay, + today = today, + firstDayOfWeek = firstDayOfWeek, + locale = locale, + dateFormat = dateFormat, + tomorrowLabel = tomorrowLabel, + yesterdayLabel = yesterdayLabel, + ) ?: todayLabel + return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay) } val timeFormat = timeOfDayFormatter(is24Hour, locale) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt new file mode 100644 index 00000000..1240c5f2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.TimeZone + +/** Lower values appear first in lists and get the upper visible lanes in grids. */ +fun EventInstance.displayPriority(zone: TimeZone): Int = when { + isDeclined -> 3 + spansMultipleDays(zone) -> 0 + isAllDay -> 1 + else -> 2 +} + +/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */ +fun eventDisplayComparator(zone: TimeZone): Comparator = + compareBy { it.displayPriority(zone) } + .thenBy { it.start } + .thenBy { it.title } + .thenBy { it.instanceId } 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 284c58df..6dde4345 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -12,16 +12,29 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.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.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 @@ -29,6 +42,15 @@ 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 @@ -37,16 +59,21 @@ 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 @@ -54,10 +81,10 @@ 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.NonCancellable import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -66,8 +93,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 @@ -116,14 +144,25 @@ 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 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()) } - // Tapping a day in the month grid opens the day view anchored to that date. - var pendingDayIso by rememberSaveable { mutableStateOf(null) } + 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() } + } + val onOpenWeek: (LocalDate) -> Unit = { date -> + focus.navigateTo(date) + switchTo { it.drillToWeek() } } // The event-detail screen (S4) is a full-screen destination hoisted here so @@ -273,13 +312,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() @@ -303,7 +343,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() } @@ -372,12 +412,16 @@ fun CalendarHost( val reduceMotion = rememberReduceMotion() val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1 if (reduceMotion) { - BackHandler(enabled = viewBackEnabled) { viewStack = viewStack.dropLast(1) } + BackHandler(enabled = viewBackEnabled) { + navigationVersion++ + viewStack = viewStack.dropLast(1) + } } else { PredictiveBackHandler(enabled = viewBackEnabled) { events -> val previous = viewStack[viewStack.size - 2] try { events.collect { viewTransition.seekTo(it.progress, targetState = previous) } + navigationVersion++ viewStack = viewStack.dropLast(1) } catch (e: CancellationException) { withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) } @@ -387,95 +431,125 @@ fun CalendarHost( } 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. What both views show morphs across // (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade. val viewSwitch = fadeThrough() 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 } }, ) { - SharedTransitionLayout(Modifier.fillMaxSize()) { + // 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. rememberTransition(viewTransition, label = "view-switch").AnimatedContent( transitionSpec = { viewSwitch }, + contentKey = { it.screen }, ) { currentView -> + val screen = currentView.screen val sharedScope = this@SharedTransitionLayout val visibilityScope = this@AnimatedContent - val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade) { - if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade) + 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) { - when (currentView) { - CalendarView.Week -> WeekScreen( - selectedView = currentView, - onSelectView = onSelectView, + CompositionLocalProvider( + LocalViewMorph provides viewMorph, + LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen), + ) { + when (screen) { + ViewScreen.Timeline -> TimelineScreen( + view = currentView, onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) - CalendarView.MultiDay -> MultiDayScreen( - selectedView = currentView, - onSelectView = onSelectView, + ViewScreen.Month -> MonthScreen( + onOpenDay = onOpenDay, + onOpenWeek = onOpenWeek, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + ) + ViewScreen.Agenda -> AgendaScreen( onOpenDay = onOpenDay, onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Day -> DayScreen( - selectedView = currentView, - onSelectView = onSelectView, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - initialDateIso = pendingDayIso, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Month -> MonthScreen( - selectedView = currentView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - ) - CalendarView.Agenda -> AgendaScreen( - selectedView = currentView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, ) } } } } } + } + } // Scope prompt + confirmation/undo snackbar for a dropped event, declared // right after the calendar views so later overlays cover it. @@ -678,3 +752,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 ca64ec54..737b0239 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,16 @@ 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 @@ -20,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 @@ -60,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 @@ -83,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) } @@ -126,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(), + ) } } @@ -328,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) @@ -370,6 +337,7 @@ internal fun AgendaContent( now = now, onEventClick = onEventClick, onOpenDay = onOpenDay, + onTopDay = onTopDay, modifier = modifier, ) } @@ -388,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, @@ -432,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( @@ -453,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/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2c5ec3ad..f38a34df 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanLastDay @@ -86,11 +87,7 @@ fun groupAgendaDays( return byDay.map { (date, dayEvents) -> AgendaDay( date = date, - events = dayEvents.sortedWith( - compareByDescending { it.isAllDay } - .thenBy { it.start } - .thenBy { it.title }, - ), + events = dayEvents.sortedWith(eventDisplayComparator(zone)), ) } } 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 4b1fcf71..d3b45662 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 00000000..173d29d2 --- /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 305f0ecb..3f4f8902 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 948d1f27..9c5c52ba 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 01033d99..7715ee27 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 @@ -129,3 +129,16 @@ 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 330fb439..fe933e5b 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/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt new file mode 100644 index 00000000..7954e3c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -0,0 +1,328 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Thickness of the out-of-view edge bar. */ +private val EDGE_BAR_THICKNESS = 3.dp + +/** Band the bar sits in, and the room a revealed event keeps from the edge. */ +private val EDGE_TOUCH_HEIGHT = 24.dp + +/** How far from the viewport's edge a tap on empty column still hits the bar. */ +private val EDGE_TAP_REACH = 16.dp + +/** + * How far the bar sits in from the viewport's edges — enough to clear the + * timeline's rounded 16dp corners on the outer columns. + */ +private val EDGE_BAR_VERTICAL_INSET = 4.dp +private val EDGE_BAR_HORIZONTAL_INSET = 6.dp + +/** Distance outside the viewport over which a bar fades before its event appears. */ +private val EDGE_FADE_LEAD = 24.dp + +/** The nearest timed blocks wholly above and below a column's viewport. */ +@Immutable +data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) { + companion object { + val None = OutOfViewEvents(null, null) + } +} + +/** Which edge of the viewport a hidden event sits past. */ +enum class ViewportEdge { Top, Bottom } + +/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */ +internal fun renderedEndMin(block: TimedBlock): Float = + maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f) + +/** + * The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] + * (minutes of the day), nearest first. A block leaves this set as soon as any + * part of its rendered height enters the viewport. + */ +fun outOfViewEvents( + blocks: List, + topMin: Float, + bottomMin: Float, +): OutOfViewEvents { + val above = blocks + .filter { renderedEndMin(it) <= topMin } + .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) + val below = blocks + .filter { it.startMin >= bottomMin } + .minWithOrNull(compareBy({ it.startMin }, { -renderedEndMin(it) })) + return OutOfViewEvents(above, below) +} + +/** + * The scroll offset that brings [block] into a [viewportPx]-tall viewport from + * past [edge], with [marginPx] of room so it clears the edge bar. An event above + * lands with its start at the top; one below lands whole at the bottom when it + * fits, and with its start at the top when it does not. + */ +fun revealScrollTarget( + block: TimedBlock, + edge: ViewportEdge, + hourPx: Float, + viewportPx: Int, + marginPx: Float, + maxScroll: Int, +): Int { + val startPx = block.startMin / 60f * hourPx + val endPx = renderedEndMin(block) / 60f * hourPx + val target = when (edge) { + ViewportEdge.Top -> startPx - marginPx + ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx) + } + return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0)) +} + +/** + * The edge whose bar a tap at [y] (content px) lands on, if that bar is showing + * and the tap is within [reachPx] of the viewport's edge. + */ +fun tappedEdge( + events: OutOfViewEvents, + y: Float, + viewportTop: Float, + viewportPx: Int, + reachPx: Float, +): ViewportEdge? = when { + events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top + events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom + else -> null +} + +/** Fade while the event is still outside; never draw its bar at the boundary. */ +internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when { + gapPx <= 0f -> 0f + fadeLeadPx <= 0f -> 1f + else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f) +} + +/** + * The out-of-view state of one day column (#188): which events hide past its + * edges, and the scroll that reveals them. + * + * The bars take no touches themselves, so event blocks under them keep their + * taps and drags; the column's own tap handler offers taps to [tap] first. + */ +@Stable +class OutOfViewEdges internal constructor( + private val scrollState: ScrollState, + private val scope: CoroutineScope, +) { + internal var blocks by mutableStateOf(emptyList()) + internal var hourPx by mutableFloatStateOf(0f) + internal var fadeLeadPx by mutableFloatStateOf(0f) + internal var marginPx by mutableFloatStateOf(0f) + internal var reachPx by mutableFloatStateOf(0f) + internal var reduceMotion = false + internal var revealSpec: FiniteAnimationSpec? = null + + val events: OutOfViewEvents by derivedStateOf { + val viewport = scrollState.viewportSize + if (viewport <= 0 || hourPx <= 0f) { + OutOfViewEvents.None + } else { + val top = scrollState.value.toFloat() + outOfViewEvents( + blocks = blocks, + topMin = top / hourPx * 60f, + bottomMin = (top + viewport) / hourPx * 60f, + ) + } + } + + /** Scrolls the event hidden past [edge] into view, if there is one. */ + fun reveal(edge: ViewportEdge) { + val block = if (edge == ViewportEdge.Top) events.above else events.below + block ?: return + val target = revealScrollTarget( + block = block, + edge = edge, + hourPx = hourPx, + viewportPx = scrollState.viewportSize, + marginPx = marginPx, + maxScroll = scrollState.maxValue, + ) + val spec = revealSpec + scope.launch { + if (reduceMotion || spec == null) { + scrollState.scrollTo(target) + } else { + scrollState.animateScrollTo(target, spec) + } + } + } + + /** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */ + fun tap(y: Float): Boolean { + val edge = tappedEdge( + events = events, + y = y, + viewportTop = scrollState.value.toFloat(), + viewportPx = scrollState.viewportSize, + reachPx = reachPx, + ) ?: return false + reveal(edge) + return true + } +} + +@Composable +fun rememberOutOfViewEdges( + blocks: List, + scrollState: ScrollState, + hourPx: Float, +): OutOfViewEdges { + val scope = rememberCoroutineScope() + val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) } + val density = LocalDensity.current + val reduceMotion = rememberReduceMotion() + val revealSpec = rememberCalendarPageSpec() + SideEffect { + edges.blocks = blocks + edges.hourPx = hourPx + edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() } + edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() } + edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() } + edges.reduceMotion = reduceMotion + edges.revealSpec = revealSpec + } + return edges +} + +/** + * Thin bars at the top and bottom of a day column's visible viewport, tinted in + * the colour of the nearest timed event hidden past that edge (#188). + * + * Lives inside the column's scrolling content and follows the viewport by + * offsetting against [scrollState] at placement rather than recomposing every + * frame. Opacity follows the gap to the hidden event and reaches zero before + * the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen + * readers get a button. + */ +@Composable +fun BoxScope.OutOfViewEdgeBars( + edges: OutOfViewEdges, + scrollState: ScrollState, + dark: Boolean, +) { + val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() } + val events = edges.events + EdgeBar( + block = events.above, + edge = ViewportEdge.Top, + dark = dark, + onReveal = edges::reveal, + scrollState = scrollState, + hourPx = edges.hourPx, + fadeLeadPx = edges.fadeLeadPx, + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(0, scrollState.value) }, + ) + EdgeBar( + block = events.below, + edge = ViewportEdge.Bottom, + dark = dark, + onReveal = edges::reveal, + scrollState = scrollState, + hourPx = edges.hourPx, + fadeLeadPx = edges.fadeLeadPx, + modifier = Modifier + .align(Alignment.TopStart) + .offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) }, + ) +} + +@Composable +private fun EdgeBar( + block: TimedBlock?, + edge: ViewportEdge, + dark: Boolean, + onReveal: (ViewportEdge) -> Unit, + scrollState: ScrollState, + hourPx: Float, + fadeLeadPx: Float, + modifier: Modifier = Modifier, +) { + block ?: return + val color = eventFill(block.event.color, dark, LocalSoftenColors.current) + val label = stringResource( + if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(EDGE_TOUCH_HEIGHT) + .graphicsLayer { + val gapPx = when (edge) { + ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx + ViewportEdge.Bottom -> block.startMin / 60f * hourPx - + (scrollState.value + scrollState.viewportSize) + } + alpha = edgeBarAlpha(gapPx, fadeLeadPx) + } + .semantics { + role = Role.Button + contentDescription = label + onClick { + onReveal(edge) + true + } + }, + contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = EDGE_BAR_HORIZONTAL_INSET, + vertical = EDGE_BAR_VERTICAL_INSET, + ) + .height(EDGE_BAR_THICKNESS) + .background(color, CircleShape), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt new file mode 100644 index 00000000..1efb9869 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.calendula.ui.common + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * The hours the week and day timelines fit to the screen when they open (#322). + * + * Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the + * hour so that [startHour]..[endHour] fills the viewport, and the timeline opens + * scrolled to [startHour]. The hours are kept when [enabled] is switched off, so + * switching it back on restores the chosen range. + */ +data class TimelineHours( + val enabled: Boolean = false, + val startHour: Int = DEFAULT_START_HOUR, + val endHour: Int = DEFAULT_END_HOUR, +) { + val span: Int get() = endHour - startHour + + companion object { + const val DEFAULT_START_HOUR = 7 + const val DEFAULT_END_HOUR = 22 + + /** Shortest range that can be chosen, in hours. */ + const val MIN_SPAN = 2 + + /** + * A range with [startHour] and [endHour] held to the day and at least + * [MIN_SPAN] apart; the start gives way when the two collide. + */ + fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours { + val end = endHour.coerceIn(MIN_SPAN, 24) + val start = startHour.coerceIn(0, end - MIN_SPAN) + return TimelineHours(enabled, start, end) + } + + /** Start hours that still leave [MIN_SPAN] before [endHour]. */ + fun startOptions(endHour: Int): List = (0..endHour - MIN_SPAN).toList() + + /** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */ + fun endOptions(startHour: Int): List = (startHour + MIN_SPAN..24).toList() + } +} + +/** + * The scroll offset that puts [startHour] at the top of a timeline laid out + * with [maxValue] scroll range over a [viewportPx] viewport, or null while that + * layout is not yet at [hourPx] per hour — the scale changes a frame before the + * content grows to match it, and scrolling against the old layout would land on + * the wrong hour. + */ +internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? { + if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null + val laidOutHourPx = (maxValue + viewportPx) / 24f + if (abs(laidOutHourPx - hourPx) > 1f) return null + return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt index d7c69f1c..227e5f6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -38,6 +38,12 @@ sealed interface TimelineScale { /** A height the user pinched to. Build it through [custom], which clamps. */ data class Custom(val hourHeight: Dp) : TimelineScale + /** + * [startHour]..[endHour] fills the viewport (#322). Applied when a timeline + * opens with [TimelineHours] on, never picked from the preset list. + */ + data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale + companion object { /** The named steps the settings picker offers, coarse to roomy. */ val presets: List = listOf(FitDay, Compact, Regular, Comfortable) @@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { is TimelineScale.Custom -> this.hourHeight .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) .coerceAtLeast(fillHourHeight(viewportHeight)) + is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat()) + .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) + .coerceAtLeast(fillHourHeight(viewportHeight)) } /** @@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular TimelineScale.Comfortable -> R.string.timeline_scale_comfortable is TimelineScale.Custom -> R.string.timeline_scale_custom + is TimelineScale.FitHours -> R.string.settings_visible_hours } @get:StringRes @@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular_summary TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary is TimelineScale.Custom -> R.string.timeline_scale_custom_summary + is TimelineScale.FitHours -> R.string.settings_visible_hours_summary } /** Marks a stored custom height; the rest of the value is its dp. */ private const val CUSTOM_PREFIX = "custom:" +/** Marks a stored hour range, as `start-end`. */ +private const val HOURS_PREFIX = "hours:" + /** * Stored names for the presets. Spelled out rather than taken from `toString()` * so R8 can't rename them out from under an existing install — and they match @@ -171,6 +185,7 @@ private val PRESET_NAMES: Map = mapOf( */ fun TimelineScale.storageValue(): String = when (this) { is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value + is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour" TimelineScale.FitDay, TimelineScale.Compact, TimelineScale.Regular, @@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when { stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull() ?.let { TimelineScale.custom(it.dp) } ?: TimelineScale.Regular + stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-') + .mapNotNull { it.toIntOrNull() } + .takeIf { it.size == 2 } + ?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) } + ?.let { TimelineScale.FitHours(it.startHour, it.endHour) } + ?: TimelineScale.Regular else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular } 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 00000000..eaf19ff2 --- /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 69527f9c..3b6d3e6e 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 @@ -49,13 +49,49 @@ class TimelineZoom( var isPinching: Boolean by mutableStateOf(false) private set + /** The visible-hours preference (#322); a timeline re-fits when it changes. */ + var hours: TimelineHours by mutableStateOf(TimelineHours()) + private set + + private var stored: TimelineScale = initial + /** * Take a value that came from the preference. Ignored mid-pinch: the stored * value is a frame or two behind the fingers there, and letting it land would - * snap the timeline back while the user is still pinching. + * snap the timeline back while the user is still pinching. With visible + * hours on it is only remembered, so a late-arriving preference can't undo + * the fit a timeline just opened with. */ fun adopt(stored: TimelineScale) { - if (!isPinching) scale = stored + this.stored = stored + if (!isPinching && !hours.enabled) scale = stored + } + + /** Take the visible-hours preference; switching it off hands back the stored scale. */ + fun adoptHours(hours: TimelineHours) { + val wasEnabled = this.hours.enabled + this.hours = hours + if (wasEnabled && !hours.enabled && !isPinching) scale = stored + } + + /** + * A timeline is opening. With visible hours on, the range replaces whatever + * scale is in force — a stored preset and a pinch alike — and its start hour + * is returned for the timeline to scroll to; otherwise null. + */ + fun open(): Int? { + val hours = hours + if (!hours.enabled) return null + if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour) + return hours.startHour + } + + /** + * Holds the height [open] fitted, so a viewport that changes afterwards (the + * all-day strip following a swipe) doesn't rescale the hours under the scroll. + */ + fun holdFit(hourHeight: Dp) { + if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight) } fun beginPinch() { @@ -66,9 +102,10 @@ class TimelineZoom( scale = TimelineScale.custom(hourHeight) } + /** Settles the pinch. With visible hours on it only lasts until the next [open]. */ fun endPinch() { isPinching = false - persist(scale) + if (!hours.enabled) persist(scale) } } @@ -82,16 +119,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) { val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom } /** - * The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches - * back through [onPersist]. + * The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing + * settled pinches back through [onPersist]. */ @Composable fun rememberTimelineZoom( stored: TimelineScale, + hours: TimelineHours, onPersist: (TimelineScale) -> Unit, ): TimelineZoom { val persist by rememberUpdatedState(onPersist) val zoom = remember { TimelineZoom(stored) { persist(it) } } + LaunchedEffect(hours) { zoom.adoptHours(hours) } // Picking a preset in Settings has to reach the timelines, and so does the // stored value arriving after the first frame. LaunchedEffect(stored) { zoom.adopt(stored) } 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 583ee4a0..d1b27d48 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 00000000..27bf4a07 --- /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 index 503cc32f..1faa9a86 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewMorph.kt @@ -7,14 +7,17 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.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 @@ -33,6 +36,7 @@ sealed interface ViewMorphKey { data class Day(val date: LocalDate) : ViewMorphKey + companion object { /** [event]'s occurrence as drawn from [day]. */ fun event(event: EventInstance, day: LocalDate): Event = @@ -47,8 +51,51 @@ class ViewMorphScope( 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 } /** @@ -77,6 +124,7 @@ fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) { @Composable fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { val morph = LocalViewMorph.current ?: return this + if (key in morph.stayingPut.keys) return this return with(morph.shared) { this@morphEvent.sharedBounds( sharedContentState = rememberSharedContentState(key), @@ -84,6 +132,7 @@ fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, zIndexInOverlay = 1f, ) @@ -102,14 +151,16 @@ fun Modifier.morphDay(date: LocalDate): Modifier { enter = fadeIn(morph.fadeSpec), exit = fadeOut(morph.fadeSpec), boundsTransform = morph.boundsTransform, + clipInOverlayDuringTransition = morph.clip, + zIndexInOverlay = 1f, ) } } -/** The view switch's travel: the M3 Expressive default spatial spring, as the pagers settle. */ -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +/** The view switch's travel, on the same spring as the timeline's columns resizing. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun rememberViewMorphBoundsTransform(): BoundsTransform { - val spec = MaterialTheme.motionScheme.defaultSpatialSpec() + 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 10e17c45..b33d28c0 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 6c817dc1..00000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ /dev/null @@ -1,1024 +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.ViewMorphKey -import de.jeanlucmakiola.calendula.ui.common.ViewMorphPage -import de.jeanlucmakiola.calendula.ui.common.morphEvent -import de.jeanlucmakiola.calendula.ui.common.morphPageActive -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.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.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.flow.first -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. We only centre on noon once, on first entry - // into the day view (i.e. when arriving from the month/week view). - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - 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) - } - - // 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) } - } - ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { - 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) - .morphEvent(ViewMorphKey.event(span.event, state.date)) - .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, - 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, - 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) - 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 -> - 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) - .morphEvent(ViewMorphKey.event(block.event, date)) - .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) - } - } - } -} - -@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 2883366a..00000000 --- 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 e310d49f..00000000 --- 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 90303d93..00000000 --- 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/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index cfc7fc89..ec628b91 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 @@ -4,13 +4,21 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterExitState import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.detectHorizontalDragGestures 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 @@ -23,10 +31,12 @@ import kotlinx.coroutines.flow.drop import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -35,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 @@ -60,28 +69,27 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +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 import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.DisposableEffect @@ -90,8 +98,8 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.remember 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 @@ -108,6 +116,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventPaint import de.jeanlucmakiola.calendula.ui.common.eventSurface import de.jeanlucmakiola.calendula.ui.common.monthBarCuts import kotlin.math.roundToInt +import kotlin.math.abs import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow @@ -122,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 @@ -136,6 +144,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -153,12 +163,9 @@ import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow +import de.jeanlucmakiola.calendula.ui.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 @@ -172,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 @@ -198,22 +204,61 @@ import kotlin.time.Instant import java.time.format.TextStyle as JavaTextStyle import java.util.Locale +private val LocalTodayPulse = staticCompositionLocalOf { 0 } +private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null } + +/** A brief push from today's full cell into its immediate neighbours. */ +@Composable +private fun rememberTodayRipple(active: Boolean): Animatable { + val pulse = remember { Animatable(0f) } + val pulseKey = LocalTodayPulse.current + val reduceMotion = rememberReduceMotion() + LaunchedEffect(pulseKey, active, reduceMotion) { + if (active && pulseKey > 0 && !reduceMotion) { + pulse.snapTo(0f) + pulse.animateTo(1f, tween(durationMillis = 180)) + pulse.animateTo( + 0f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } else { + pulse.snapTo(0f) + } + } + return pulse +} + +/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */ +private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier { + if (relativeColumn == null || abs(relativeColumn) > 1) return this + return graphicsLayer { + val pulse = progress() + if (relativeColumn == 0) { + scaleX = 1f + 0.10f * pulse + scaleY = 1f + 0.06f * pulse + } else { + translationX = (if (relativeColumn < 0) -1f else 1f) * 4.dp.toPx() * pulse + scaleX = 1f - 0.025f * pulse + } + } +} + @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() @@ -230,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() } } @@ -255,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 @@ -326,12 +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 @@ -354,12 +418,23 @@ fun MonthScreen( scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) } } 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() @@ -369,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) @@ -381,131 +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 -> selectedDate - 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, - ) { - if (scrolling) { - ContinuousMonthContent( - state = continuousState, - listState = listState, - dense = dense, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } else if (viewStyle == MonthViewStyle.Split) { - SplitMonthContent( - state = state, - selected = selectedDate, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - // A tapped leading/trailing day follows to its own - // month, pager and all — straight away, or a swipe - // still settling would land and take the tap back. - onSelectDay = { date -> - viewModel.selectDate(date) - jumpToMonth(YearMonth(date.year, date.month)) - }, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = { onCreateEvent(it, null) }, - ) - } else { - MonthContent( - state = state, - pagerState = pagerState, - weekStart = weekStart, - monthState = viewModel::month, - showWeekNumbers = showWeekNumbers, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - ) - } + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + ) + } else if (viewStyle == MonthViewStyle.Split || 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) } } } @@ -774,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() @@ -882,7 +886,7 @@ private fun rememberWeekNumberGutter(): Dp { val style = weekNumberStyle() return remember(style, density, measurer) { val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() } - text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2 + (text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp) } } @@ -1309,11 +1313,17 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp @Composable private fun SplitMonthContent( state: MonthUiState, - selected: LocalDate, + selected: LocalDate?, + initiallyExpanded: Boolean, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, pagerState: PagerState, weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onRetry: () -> Unit, onSelectDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit, @@ -1322,15 +1332,23 @@ private fun SplitMonthContent( ) { val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - var expanded by rememberSaveable { mutableStateOf(false) } - // Back collapses before it does anything else. Expanding replaces the whole - // screen, so it is a place you can be, and every other place in the app can - // be backed out of. Declared deeper than CalendarHost's view-stack handler, - // which is what makes it win while it is enabled. - BackHandler(enabled = expanded) { expanded = false } + val haptics = LocalHapticFeedback.current + BackHandler(enabled = expanded != initiallyExpanded) { + onExpandedChange(initiallyExpanded) + } val expandDrag = rememberSplitExpandDrag( - onExpand = { expanded = true }, - onCollapse = { expanded = false }, + onExpand = { + if (!expanded) { + onExpandedChange(true) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + }, + onCollapse = { + if (expanded) { + onExpandedChange(false) + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + }, ) when (state) { @@ -1377,13 +1395,13 @@ private fun SplitMonthContent( // the day you picked. onPickDay = { onSelectDay(it) - expanded = false + onExpandedChange(false) }, // A tapped chip is asking for that event, not for its // day, so it opens the detail from here too rather // than collapsing (#187). onEventClick = onEventClick, - onCollapse = { expanded = false }, + onCollapse = { onExpandedChange(false) }, ) } else { SplitMonthCollapsed( @@ -1393,10 +1411,13 @@ private fun SplitMonthContent( showWeekNumbers = showWeekNumbers, expandDrag = expandDrag, onSelectDay = onSelectDay, + onPaneSwipeNext = onPaneSwipeNext, + onPaneSwipePrev = onPaneSwipePrev, + onClearSelection = onClearSelection, onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = onCreateEvent, - onExpand = { expanded = true }, + onExpand = { onExpandedChange(true) }, ) } } @@ -1418,10 +1439,13 @@ private class SplitMonths( private fun SplitMonthCollapsed( state: MonthUiState.Success, months: SplitMonths, - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, expandDrag: Modifier, onSelectDay: (LocalDate) -> Unit, + onPaneSwipeNext: () -> Unit, + onPaneSwipePrev: () -> Unit, + onClearSelection: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, @@ -1460,18 +1484,64 @@ private fun SplitMonthCollapsed( } SplitExpandHandle(expanded = false, onToggle = onExpand) } - SplitDayPane( - date = selected, - today = state.today, - // Null, not empty: the selection moves to the new month before - // its data arrives, and a missing key means "not loaded yet". - // Passing an empty list would claim the day was free. - events = state.instancesByDay[selected], - zone = state.zone, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onCreateEvent = onCreateEvent, - modifier = Modifier.weight(1f).fillMaxWidth(), + Box( + Modifier.weight(1f).fillMaxWidth() + .then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)), + ) { + if (selected == null) { + SplitMonthAgendaPane( + state = state, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + ) + } else { + Column { + InputChip( + selected = true, + onClick = onClearSelection, + label = { Text(agendaDayLabel(selected, state.today)) }, + trailingIcon = { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.month_split_clear_filter), + ) + }, + modifier = Modifier.padding(start = 16.dp), + ) + SplitDayPane( + date = selected, + today = state.today, + events = state.instancesByDay[selected], + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modifier { + val threshold = with(LocalDensity.current) { 24.dp.toPx() } + val currentNext by rememberUpdatedState(onNext) + val currentPrev by rememberUpdatedState(onPrev) + return Modifier.pointerInput(threshold) { + var distance = 0f + var fired = false + detectHorizontalDragGestures( + onDragStart = { distance = 0f; fired = false }, + onHorizontalDrag = { _, drag -> + distance += drag + if (!fired && kotlin.math.abs(distance) >= threshold) { + fired = true + if (distance < 0f) currentNext() else currentPrev() + } + }, ) } } @@ -1489,7 +1559,7 @@ private fun SplitMonthCollapsed( private fun SplitMonthExpanded( months: SplitMonths, /** Marked nowhere here; it only anchors the outline's morph. */ - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, @@ -1562,7 +1632,7 @@ private fun SplitExpandHandle( * it's roughly a third of a paged row — which is the point: the space it gives * up goes to the day pane below. */ -private val SPLIT_ROW_HEIGHT = 46.dp +private val SPLIT_ROW_HEIGHT = 48.dp /** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */ private val SPLIT_GRID_VERTICAL_PADDING = 4.dp @@ -1601,13 +1671,15 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp @Composable internal fun SplitMonthGrid( state: MonthUiState.Success, - selected: LocalDate, + selected: LocalDate?, showWeekNumbers: Boolean, onSelectDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { val dark = isSystemInDarkTheme() val month = state.month + val hasToday = state.today.year == month.year && state.today.month == month.month + val ripple = rememberTodayRipple(active = hasToday) Column( modifier = modifier .fillMaxWidth() @@ -1615,6 +1687,7 @@ internal fun SplitMonthGrid( verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP), ) { state.weeks.forEach { week -> + val todayCol = week.days.indexOf(state.today).takeIf { hasToday && it >= 0 } Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { if (showWeekNumbers) { WeekNumberGutter( @@ -1645,6 +1718,8 @@ internal fun SplitMonthGrid( // page transition. isSelected = day == selected && inMonth, inMonth = inMonth, + relativeToToday = todayCol?.let { col - it }, + rippleProgress = { ripple.value }, dark = dark, onClick = { onSelectDay(day) }, modifier = Modifier.weight(1f).fillMaxHeight(), @@ -1671,6 +1746,8 @@ private fun SplitDayCell( isToday: Boolean, isSelected: Boolean, inMonth: Boolean, + relativeToToday: Int?, + rippleProgress: () -> Float, dark: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -1679,6 +1756,7 @@ private fun SplitDayCell( // the whole cell, which read as a second state on top of the day rather than // as a mark on it — and fought today's own circle when they coincided. val background = when { + isToday && inMonth -> MaterialTheme.colorScheme.primaryContainer inMonth -> MaterialTheme.colorScheme.surfaceContainer else -> MaterialTheme.colorScheme.surfaceContainerLow } @@ -1711,6 +1789,7 @@ private fun SplitDayCell( Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) + .todayRipple(relativeToToday, rippleProgress) .selectable(selected = isSelected, onClick = onClick), contentAlignment = Alignment.TopCenter, ) { @@ -1836,6 +1915,54 @@ private fun SplitDots( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SplitMonthAgendaPane( + state: MonthUiState.Success, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, +) { + val start = agendaStartForMonth(state.month, state.today) + val days = state.instancesByDay.keys + .filter { it >= start && it.year == state.month.year && it.month == state.month.month } + .filter { state.instancesByDay[it].orEmpty().isNotEmpty() } + .toMutableList() + .apply { if (start !in this) add(start) } + .sorted() + val dimCutoff = LocalDimCutoff.current + val listState = key(state.month) { rememberLazyListState() } + LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) { + days.forEach { day -> + val events = state.instancesByDay[day].orEmpty() + stickyHeader(key = "header-$day") { + AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay) + } + if (events.isEmpty()) { + item(key = "empty-$day") { + AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(day) }, + ) + } + } else { + itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event -> + AgendaEventRow( + event = event, + day = day, + zone = state.zone, + position = positionOf(index, events.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + modifier = animateItemMotion(), + onClick = { onEventClick(event) }, + ) + } + } + item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) } + } + } +} + /** * The selected day's events, in the agenda's own row vocabulary so the two * surfaces read as one app. The date header opens the full Day view, matching @@ -2059,6 +2186,8 @@ private fun MonthWeekRow( selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() + val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) } + val ripple = rememberTodayRipple(active = todayCol != null) val overflowRow = rememberOverflowRowHeight() val laneCap = week.laneCapFor(rowHeight, overflowRow) val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 @@ -2178,15 +2307,17 @@ private fun MonthWeekRow( // Spanning bars draw on top of these, bridging cells, so they still read // as one continuous event. Row(Modifier.matchParentSize()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> Box( Modifier .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) .morphElement(MonthMorphKey.Cell(d)) + .todayRipple(todayCol?.let { col - it }) { ripple.value } .background( color = when { + d == today && inMonth(d) -> MaterialTheme.colorScheme.primaryContainer inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer // A blanked cell draws nothing at all: it is // the gap that shows where the month starts. @@ -2201,7 +2332,7 @@ private fun MonthWeekRow( Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Row(Modifier.fillMaxWidth()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> if (blankOutside && !inMonth(d)) { Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT)) } else { @@ -2218,7 +2349,8 @@ private fun MonthWeekRow( }, modifier = Modifier .weight(1f) - .morphElement(MonthMorphKey.DayNumber(d)), + .morphElement(MonthMorphKey.DayNumber(d)) + .todayRipple(todayCol?.let { col - it }) { ripple.value }, ) } } @@ -2351,6 +2483,7 @@ private fun MonthWeekRow( ) .width(colW) .height(EVENT_ROW_HEIGHT) + .todayRipple(todayCol?.let { col - it }) { ripple.value } .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } @@ -2584,8 +2717,13 @@ private fun monthChipDragModifier( private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() } val label = stringResource(R.string.week_number_label) + val openLabel = stringResource(R.string.month_open_week, weekNumber) + val onOpenWeek = LocalOpenWeek.current Box( modifier = modifier + .then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) { + onOpenWeek(weekStart) + } else Modifier) .padding(horizontal = CELL_GAP, vertical = 1.dp) .background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE) .semantics { contentDescription = "$label $weekNumber" }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 04a5af2f..cd227711 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.common.PageStateCache import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.eventDisplayComparator import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays @@ -292,12 +293,10 @@ class MonthViewModel @Inject constructor( // --- Split style (#53) ------------------------------------------------ // - // The first selected-day concept in the app: the other views drill straight - // into a date, while the split style keeps one selected and lists it below - // the grid. + // Null means the full-month agenda; a date narrows the pane to that day. - private val _selectedDate = MutableStateFlow(todayDate) - val selectedDate: StateFlow = _selectedDate + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate /** * Select [date], following it to its month if it sits in the grid's leading @@ -305,23 +304,33 @@ class MonthViewModel @Inject constructor( * silently list a date the grid isn't pointing at. */ fun selectDate(date: LocalDate) { - _selectedDate.value = date val target = YearMonth(date.year, date.month) + _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date if (target != _month.value) _month.value = target } - /** - * Move the selection with the month: today if the new month holds it, else - * its 1st. Leaving the old date selected would list a day the grid no longer - * shows. - */ + fun clearSelection() { _selectedDate.value = null } + + /** A pane swipe walks days when filtered, months when showing the agenda. */ + fun stepSplitPane(days: Int) { + val selected = _selectedDate.value + if (selected == null) { + _month.value = yearMonthForIndex(monthIndexOf(_month.value) + days) + } else { + val next = selected.plus(days, DateTimeUnit.DAY) + _selectedDate.value = next + _month.value = YearMonth(next.year, next.month) + } + } + + /** Moving the grid to another month restores its full agenda. */ private fun realignSelection() { - _selectedDate.value = selectionForMonth(_month.value, todayDate) + _selectedDate.value = null } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) - _selectedDate.value = todayDate + _selectedDate.value = null } /** @@ -330,13 +339,12 @@ class MonthViewModel @Inject constructor( * otherwise sit wherever paged navigation last left it (today's month, on a * fresh open) — and a switch to a paged style, or a reseed of the other * scrolling style's list, would jump there instead of holding position. The - * selection is realigned alongside it so that landing on the Split style - * shows a live day in the month on screen rather than a stale, off-month one. + * selection is cleared alongside it so Split opens the visible month's agenda. */ fun syncScrollMonth(ym: YearMonth) { if (_month.value == ym) return _month.value = ym - _selectedDate.value = selectionForMonth(ym, todayDate) + _selectedDate.value = null } /** Jump to the month containing [date] (drawer jump-to-date). */ @@ -499,11 +507,7 @@ internal fun instancesByDay( days.associateWith { day -> instances .filter { it.coversDay(day, zone) } - .sortedWith( - compareBy { it.isDeclined } - .thenByDescending { it.isAllDay } - .thenBy { it.start }, - ) + .sortedWith(eventDisplayComparator(zone)) } /** @@ -544,11 +548,10 @@ private const val WINDOW_EDGE = 2 internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0) /** - * Which day the split style should select when the grid lands on [month]: - * [today] when the month holds it, otherwise the 1st. Pure so the rule can be - * tested without standing up a view model and a provider behind it. + * First day shown in the split agenda: today in the current month, otherwise + * the first of the visible month. */ -internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = +internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate = if (YearMonth(today.year, today.month) == month) { today } else { 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 8f71abce..defdd8b7 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/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 2741f5ad..16111c17 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -64,6 +65,7 @@ data class SettingsUiState( val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** How tall an hour is drawn in the week and day timelines (#56). */ val timelineScale: TimelineScale = TimelineScale.Regular, + val timelineHours: TimelineHours = TimelineHours(), /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 3ca82ab6..75ebca18 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings @@ -180,9 +181,11 @@ class SettingsViewModel @Inject constructor( prefs.drawerViewOrder, prefs.monthViewStyle, prefs.widgetSize, - prefs.timelineScale, - ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> - ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) + combine(prefs.timelineScale, prefs.timelineHours, ::Pair), + ) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) -> + ViewCustomization( + quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours, + ) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -209,6 +212,7 @@ class SettingsViewModel @Inject constructor( monthViewStyle = misc.viewCustomization.monthViewStyle, widgetSize = misc.viewCustomization.widgetSize, timelineScale = misc.viewCustomization.timelineScale, + timelineHours = misc.viewCustomization.timelineHours, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -328,6 +332,7 @@ class SettingsViewModel @Inject constructor( val monthViewStyle: MonthViewStyle, val widgetSize: WidgetSize, val timelineScale: TimelineScale, + val timelineHours: TimelineHours, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -626,6 +631,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimelineScale(scale) } } + fun setTimelineHours(hours: TimelineHours) { + viewModelScope.launch { prefs.setTimelineHours(hours) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } 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 827cd073..22ccb1ae 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 @@ -34,9 +34,12 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.descriptionRes +import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.label @@ -75,6 +78,9 @@ internal fun ViewsScreen( var showAgendaScreenRange by remember { mutableStateOf(false) } var showTimelineScale by remember { mutableStateOf(false) } var showMultiDayCount by remember { mutableStateOf(false) } + var showHoursStart by remember { mutableStateOf(false) } + var showHoursEnd by remember { mutableStateOf(false) } + val hours = state.timelineHours CollapsingScaffold( title = stringResource(R.string.settings_section_views), @@ -172,10 +178,49 @@ internal fun ViewsScreen( ) GroupedRow( title = stringResource(R.string.settings_timeline_scale), - summary = stringResource(state.timelineScale.labelRes), + summary = if (hours.enabled) { + stringResource(R.string.settings_timeline_scale_by_visible_hours) + } else { + stringResource(state.timelineScale.labelRes) + }, position = Position.Middle, - onClick = { showTimelineScale = true }, + dimmed = hours.enabled, + onClick = if (hours.enabled) null else ({ showTimelineScale = true }), ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours), + summary = if (hours.enabled) { + stringResource( + R.string.settings_visible_hours_on_summary, + hourLabel(hours.startHour), + hourLabel(hours.endHour), + ) + } else { + stringResource(R.string.settings_visible_hours_summary) + }, + position = Position.Middle, + trailing = { + Switch( + checked = hours.enabled, + onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) }, + ) + }, + onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) }, + ) + if (hours.enabled) { + GroupedRow( + title = stringResource(R.string.settings_visible_hours_start), + summary = hourLabel(hours.startHour), + position = Position.Middle, + onClick = { showHoursStart = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours_end), + summary = hourLabel(hours.endHour), + position = Position.Middle, + onClick = { showHoursEnd = true }, + ) + } GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), @@ -380,6 +425,30 @@ internal fun ViewsScreen( onDismiss = { showTimelineScale = false }, ) } + if (showHoursStart) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_start), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) }, + predictiveBack = true, + options = TimelineHours.startOptions(hours.endHour), + selected = hours.startHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) }, + onDismiss = { showHoursStart = false }, + ) + } + if (showHoursEnd) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_end), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) }, + predictiveBack = true, + options = TimelineHours.endOptions(hours.startHour), + selected = hours.endHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) }, + onDismiss = { showHoursEnd = false }, + ) + } if (showAgendaScreenRange) { AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), @@ -477,3 +546,8 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) @Composable private fun multiDayLabel(count: Int): String = pluralStringResource(R.plurals.view_multi_day, count, count) + +/** A whole hour in the app's clock convention; 24 reads as the end of the day. */ +@Composable +private fun hourLabel(hour: Int): String = + formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale()) 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 00000000..42a0ed4b --- /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 00000000..ae527670 --- /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 5c2d92de..e19ed1ee 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 b9e9b80d..d717394a 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 @@ -132,13 +137,18 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine +import de.jeanlucmakiola.calendula.ui.common.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 @@ -157,9 +167,7 @@ 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.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -183,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 @@ -190,90 +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, modifier: Modifier = Modifier, - viewModel: WeekViewModel = hiltViewModel(), -) = PagedTimelineScreen( - viewModel = viewModel, - showWeekNumber = true, - selectedView = selectedView, - onSelectView = onSelectView, - onOpenDay = onOpenDay, - onEventClick = onEventClick, - onOpenSettings = onOpenSettings, - onOpenSearch = onOpenSearch, - onCreateEvent = onCreateEvent, - quickSwitchViews = quickSwitchViews, - drawerViewOrder = drawerViewOrder, - todayInToolbar = todayInToolbar, - modifier = modifier, -) + weekViewModel: WeekViewModel = hiltViewModel(), + multiDayViewModel: MultiDayViewModel = hiltViewModel(), + dayViewModel: DayViewModel = hiltViewModel(), +) { + val viewModels = remember(weekViewModel, multiDayViewModel, dayViewModel) { + TimelineViewModels(weekViewModel, multiDayViewModel, dayViewModel) + } + PagedTimelineScreen( + view = view, + viewModels = viewModels, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = modifier, + ) +} + +/** 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), -/** 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, // 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). @@ -282,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 @@ -304,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. @@ -320,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) @@ -329,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(), - ) - } } } } @@ -408,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, @@ -419,16 +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). We only centre on - // noon once, on first entry into the week view (i.e. when arriving from the - // month/day view), not on every swipe. - val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - 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) - } + // 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. @@ -447,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) @@ -487,7 +572,7 @@ private fun WeekContent( private fun WeekPager( pagerState: PagerState, grid: PageGrid, - showWeekNumber: Boolean, + kind: TimelineKind, span: (DaySpan) -> StateFlow, today: LocalDate, initialStripHeight: Dp, @@ -502,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, @@ -533,6 +618,7 @@ private fun WeekPager( ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { WeekPage( state = loaded ?: empty, + headerBias = kind.headerBias, allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController, @@ -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,126 +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.morphDay(date).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, - modifier = Modifier.morphDay(date), - ) } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.morphDay(date), + ) } } } @@ -813,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, @@ -855,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() @@ -866,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, @@ -876,29 +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, - ) - .morphEvent(ViewMorphKey.event(span.event, state.days[span.startCol])) - .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) } } } } @@ -939,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, @@ -947,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 @@ -960,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 @@ -981,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) @@ -997,29 +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, - 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(), + ) } } } @@ -1032,13 +1260,19 @@ private fun DayColumnCard( date: LocalDate, today: LocalDate, hourHeight: Dp, + 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. @@ -1051,6 +1285,8 @@ private fun DayColumnCard( // 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 columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -1068,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 @@ -1078,6 +1315,7 @@ private fun DayColumnCard( // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { detectTapGestures { offset -> + if (currentEdges.value.tap(offset.y)) return@detectTapGestures currentOnCreateAt.value( date, tappedMinuteOfDay(offset.y, currentHourPx.value), @@ -1085,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 @@ -1094,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 @@ -1116,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, @@ -1128,9 +1375,9 @@ private fun DayColumnCard( onClick = { onEventClick(block.event) }, onDrop = onDrop, modifier = Modifier - .offset(x = place.x, y = place.y + topInset) - .morphEvent(ViewMorphKey.event(block.event, date)) - .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), ) @@ -1141,9 +1388,24 @@ private fun DayColumnCard( NowLine(date = date, hourHeight = hourHeight) } } + OutOfViewEdgeBars( + edges = edges, + scrollState = scrollState, + dark = dark, + ) + } } } +/** 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, @@ -1267,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), @@ -1291,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. @@ -1359,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 fef5385f..e226fe67 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 @@ -9,9 +9,9 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.displayPriority import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure -import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.PageStateCache import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -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) } @@ -150,6 +163,7 @@ abstract class PagedTimelineViewModel( } fun goToToday() = jumpTo(todayDate) + fun goToDate(date: LocalDate) = jumpTo(date) private fun buildState( span: DaySpan, @@ -208,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; @@ -227,7 +255,13 @@ internal fun layoutAllDay( } // Declined bars are packed after every other one, so on any day they // cover they land in a lane below it (#230). - .sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol })) + .sortedWith( + compareBy { it.event.displayPriority(zone) } + .thenBy { it.startCol } + .thenBy { it.endCol } + .thenBy { it.event.start } + .thenBy { it.event.instanceId }, + ) // What each lane already holds, rather than just how far right it reaches. // A single "last occupied column" only answers correctly while spans arrive 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 8a7feee7..d336cdf3 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e912658d..cf9413d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,10 @@ Calendula A modern calendar. - - Calendar + + Calendar Loading… @@ -54,7 +54,9 @@ Previous month Next month + Open week %1$d Today + Show full month agenda More actions Open menu Settings @@ -67,6 +69,10 @@ Today + + Earlier events + Later events + Back Edit @@ -349,6 +355,7 @@ Snooze Dismiss + Today Tomorrow Yesterday @@ -478,6 +485,7 @@ Multi-day view How many days the multi-day view shows side by side. Today opens in the first column. Hour height + Set by Visible hours How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between. Fit whole day All 24 hours on one screen, no scrolling @@ -489,6 +497,13 @@ Roomier blocks, more scrolling Custom The height you pinched the timeline to + Visible hours + Fit a range of hours to the screen when week, multi-day or day view opens + %1$s – %2$s fills the screen when a view opens + From + Until + The hour at the top of the screen when week, multi-day or day view opens. Earlier hours are still a scroll away. + The hour at the bottom of the screen when a view opens. Later hours are still a scroll away, and pinching zooms until the view is opened again. Dim completed events Fade events that have already ended in month and week view Drag to reschedule @@ -537,7 +552,7 @@ Month view Month view style Pages - One month fills the screen. Swipe left or right to change month. + One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda. Scrolling months Each month sits under its own heading, with a little space setting it apart from the next. Seamless weeks diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 7c77993f..42fc64f6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first @@ -125,6 +126,24 @@ class SettingsPrefsTest { assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable) } + @Test + fun `visible hours default off and round-trip`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 7, 22)) + prefs.setTimelineHours(TimelineHours(enabled = true, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 6, 20)) + // Switching off keeps the chosen hours for next time. + prefs.setTimelineHours(TimelineHours(enabled = false, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 6, 20)) + } + + @Test + fun `visible hours are stored valid`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setTimelineHours(TimelineHours(enabled = true, 23, 22)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 20, 22)) + } + @Test fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index c8014a1b..13d20b6c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -38,6 +38,7 @@ class ReminderTimeTextTest { is24Hour = is24Hour, today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(), firstDayOfWeek = firstDayOfWeek, + todayLabel = "Today", tomorrowLabel = "Tomorrow", yesterdayLabel = "Yesterday", ) @@ -130,38 +131,74 @@ class ReminderTimeTextTest { } @Test - fun `all-day single day shows one date, read in UTC`() { + fun `all-day single day today reads as today, read in UTC`() { val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), endMillis = utcMidnight(LocalDate.of(2026, 6, 12)), isAllDay = true, // Zone must not matter for all-day events: UTC midnight is - // 02:00 in Berlin — naive local reading would shift the day. + // the previous evening in Los Angeles — naive local reading would shift the day. + zone = ZoneId.of("America/Los_Angeles"), today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026") + assertThat(text).isEqualTo("Today") } @Test - fun `all-day event tomorrow still shows the exact date, no relative prefix`() { + fun `all-day event tomorrow reads as tomorrow`() { val text = text( beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("12.06.2026") + assertThat(text).isEqualTo("Tomorrow") } @Test - fun `all-day multi-day shows the last covered day, not the exclusive end`() { + fun `all-day event later this week reads as the short weekday`() { + // 2026-06-11 is a Thursday; Saturday the 13th shares its week. val text = text( - beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)), - endMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + beginMillis = utcMidnight(LocalDate.of(2026, 6, 13)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 14)), + isAllDay = true, + locale = Locale.US, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Sat") + } + + @Test + fun `all-day event next week falls back to the exact date`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 16)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 17)), isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") + assertThat(text).isEqualTo("16.06.2026") + } + + @Test + fun `all-day multi-day reads relative start and the last covered day`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)), + endMillis = utcMidnight(LocalDate.of(2026, 6, 14)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("Tomorrow – 13.06.2026") + } + + @Test + fun `all-day multi-day further out keeps both dates`() { + val text = text( + beginMillis = utcMidnight(LocalDate.of(2026, 7, 1)), + endMillis = utcMidnight(LocalDate.of(2026, 7, 3)), + isAllDay = true, + today = LocalDate.of(2026, 6, 11), + ) + assertThat(text).isEqualTo("01.07.2026 – 02.07.2026") } @Test @@ -173,6 +210,6 @@ class ReminderTimeTextTest { isAllDay = true, today = LocalDate.of(2026, 6, 11), ) - assertThat(text).isEqualTo("11.06.2026") + assertThat(text).isEqualTo("Today") } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt index caed3b22..0446a90f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -156,4 +156,5 @@ class EventToneTest { assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer) .isEqualTo(0xFF000000.toInt()) } + } 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 00000000..4e5c7b1b --- /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/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt index 37d01d5f..aee5190b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -77,6 +77,16 @@ class GroupAgendaDaysTest { .containsExactly(LocalDate(2026, 7, 2)) } + @Test + fun `multi-day events precede single all-day and timed events`() { + val multi = event(1, "Multi", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true) + val single = event(2, "Single", at(2026, 7, 2), at(2026, 7, 3), isAllDay = true) + val timed = event(3, "Timed", at(2026, 7, 2, 8), at(2026, 7, 2, 9)) + val second = days(listOf(timed, single, multi)).first { it.date == LocalDate(2026, 7, 2) } + assertThat(second.events.map { it.title }) + .containsExactly("Multi", "Single", "Timed").inOrder() + } + @Test fun `an event ending exactly at midnight does not reach the next day`() { val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt new file mode 100644 index 00000000..6268b26f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -0,0 +1,147 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +/** Which events a column's edge bars point at, and where a tap scrolls (#188). */ +class OutOfViewEdgesTest { + + private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock( + event = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "Event $id", + start = Instant.fromEpochMilliseconds(0), + end = Instant.fromEpochMilliseconds(0), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ), + startMin = startMin, + endMin = endMin, + lane = 0, + laneCount = 1, + ) + + private val early = block(1, 7 * 60, 8 * 60) + private val breakfast = block(2, 8 * 60, 9 * 60) + private val lunch = block(3, 12 * 60, 13 * 60) + private val dinner = block(4, 19 * 60, 20 * 60) + private val late = block(5, 21 * 60, 22 * 60) + + // Viewport 10:00–17:00. + private fun outOf(vararg blocks: TimedBlock) = + outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f) + + @Test + fun `the nearest hidden event on each side is picked`() { + val events = outOf(early, breakfast, lunch, dinner, late) + assertThat(events.above).isEqualTo(breakfast) + assertThat(events.below).isEqualTo(dinner) + } + + @Test + fun `an event in view points nowhere`() { + assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None) + } + + @Test + fun `an event cut by the edge is not out of view`() { + val straddling = block(6, 9 * 60, 11 * 60) + assertThat(outOf(straddling).above).isNull() + } + + @Test + fun `an event disappears from the edge as soon as a sliver enters view`() { + val sliver = block(6, 9 * 60, 10 * 60 + 5) + assertThat(outOf(sliver).above).isNull() + val tip = block(7, 17 * 60 - 5, 18 * 60) + assertThat(outOf(tip).below).isNull() + val justAbove = block(8, 9 * 60, 10 * 60) + val justBelow = block(9, 17 * 60, 18 * 60) + assertThat(outOf(justAbove).above).isEqualTo(justAbove) + assertThat(outOf(justBelow).below).isEqualTo(justBelow) + } + + @Test + fun `the bar fades before its event reaches the viewport`() { + assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f) + assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f) + assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f) + assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f) + } + + @Test + fun `a column scrolled to its top points at nothing above it`() { + val first = block(6, 0, 30) + val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f) + assertThat(events.above).isNull() + } + + @Test + fun `a short event is judged by the height it renders at`() { + // Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00. + val blip = block(6, 9 * 60 + 53, 9 * 60 + 58) + assertThat(outOf(blip).above).isNull() + } + + @Test + fun `an event above scrolls to its start less the margin`() { + val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(780) + } + + @Test + fun `an event below that fits lands whole at the bottom`() { + val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + // Its end, 2000, plus the margin, sits at the viewport's bottom. + assertThat(target).isEqualTo(1320) + } + + @Test + fun `an event below taller than the viewport lands with its start at the top`() { + val long = block(6, 12 * 60, 22 * 60) + val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) + assertThat(target).isEqualTo(1180) + } + + @Test + fun `the target stays within the scroll range`() { + val first = block(6, 0, 30) + assertThat( + revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(0) + val last = block(7, 23 * 60, 24 * 60) + assertThat( + revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700), + ).isEqualTo(1700) + } + + // Viewport 1000..1700 px with both bars showing, 40 px of reach. + private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) = + tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f) + + @Test + fun `a tap near an edge with a bar hits that bar`() { + assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top) + assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom) + } + + @Test + fun `a tap past the bar's reach is left to the column`() { + assertThat(tapped(1041f)).isNull() + assertThat(tapped(1350f)).isNull() + assertThat(tapped(1659f)).isNull() + } + + @Test + fun `a tap near an edge without a bar is left to the column`() { + assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull() + assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt new file mode 100644 index 00000000..f7c06109 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TimelineHoursTest { + + private val viewport = 600.dp + + @Test + fun `the chosen range exactly fills the viewport`() { + // 07–22 is 15 hours: 600dp / 15 = 40dp an hour. + assertThat(TimelineScale.FitHours(7, 22).hourHeight(viewport)).isEqualTo(40.dp) + } + + @Test + fun `the whole day fits like FitDay`() { + assertThat(TimelineScale.FitHours(0, 24).hourHeight(viewport)) + .isEqualTo(TimelineScale.FitDay.hourHeight(viewport)) + } + + @Test + fun `a short range on a tall screen stops at the pinch ceiling`() { + assertThat(TimelineScale.FitHours(9, 11).hourHeight(2000.dp)).isEqualTo(MAX_PINCH_HOUR_HEIGHT) + } + + @Test + fun `ranges are held to the day and the minimum span`() { + assertThat(TimelineHours.of(true, -3, 30)).isEqualTo(TimelineHours(true, 0, 24)) + assertThat(TimelineHours.of(true, 23, 22)).isEqualTo(TimelineHours(true, 20, 22)) + assertThat(TimelineHours.of(true, 5, 1)).isEqualTo(TimelineHours(true, 0, 2)) + } + + @Test + fun `the pickers only offer valid pairings`() { + assertThat(TimelineHours.startOptions(22)).isEqualTo((0..20).toList()) + assertThat(TimelineHours.endOptions(7)).isEqualTo((9..24).toList()) + } + + @Test + fun `opening scrolls to the start hour once the layout is at the fitted height`() { + // 40px an hour over a 600px viewport: 960px of content, 360px of scroll. + assertThat(startHourScroll(maxValue = 360, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isEqualTo(280) + } + + @Test + fun `a layout still at the previous scale is waited out`() { + // Still 56px an hour: 1344 - 600 = 744 of scroll. + assertThat(startHourScroll(maxValue = 744, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isNull() + // Not laid out at all yet. + assertThat(startHourScroll(maxValue = Int.MAX_VALUE, viewportPx = 0, startHour = 7, hourPx = 40f)) + .isNull() + } + + @Test + fun `the whole day needs no scroll`() { + assertThat(startHourScroll(maxValue = 0, viewportPx = 600, startHour = 0, hourPx = 25f)) + .isEqualTo(0) + } + + @Test + fun `a fitted range round-trips through storage`() { + val scale = TimelineScale.FitHours(6, 20) + assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale) + assertThat(parseTimelineScale("hours:x-2")).isEqualTo(TimelineScale.Regular) + } + + @Test + fun `opening with visible hours on replaces a pinched scale`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.custom(90.dp)) { persisted += it } + zoom.adoptHours(TimelineHours(true, 7, 22)) + + assertThat(zoom.open()).isEqualTo(7) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + // A pinch overrides it for the visit but is not saved over the preference. + zoom.beginPinch() + zoom.pinchTo(100.dp) + zoom.endPinch() + assertThat(zoom.scale).isEqualTo(TimelineScale.custom(100.dp)) + assertThat(persisted).isEmpty() + + // Opening again fits the range again. + zoom.open() + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + } + + @Test + fun `a late stored scale does not undo the fit, and switching off restores it`() { + val zoom = TimelineZoom(TimelineScale.Regular) {} + zoom.adoptHours(TimelineHours(true, 7, 22)) + zoom.open() + zoom.adopt(TimelineScale.Comfortable) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + zoom.adoptHours(TimelineHours(false, 7, 22)) + assertThat(zoom.open()).isNull() + assertThat(zoom.scale).isEqualTo(TimelineScale.Comfortable) + } + + @Test + fun `with visible hours off a pinch is still saved`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.Regular) { persisted += it } + assertThat(zoom.open()).isNull() + zoom.beginPinch() + zoom.pinchTo(70.dp) + zoom.endPinch() + assertThat(persisted).containsExactly(TimelineScale.custom(70.dp)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt index fb73dc54..aae8d28d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -65,6 +65,14 @@ class ViewBackStackTest { assertThat(stack.drillToDay()).isEqualTo(stack) } + @Test + fun `week number drills into week and back returns to month`() { + val stack = listOf(CalendarView.Month).drillToWeek() + assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder() + assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month) + assertThat(stack.drillToWeek()).isEqualTo(stack) + } + @Test fun `agenda widget date tap backs out agenda then home`() { // openDate(source = Agenda): root in Agenda over the Week home, then drill. 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 00000000..538c7da0 --- /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/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index ef2c56b8..71e9df28 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -222,17 +222,17 @@ class ContinuousMonthIndexTest { } @Test - fun `the split selection follows the month, landing on today when it's there`() { + fun `the split agenda starts at today in the current month`() { val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today) } @Test - fun `the split selection falls to the 1st of any other month`() { + fun `the split agenda starts at the 1st of another month`() { val today = LocalDate(2026, 6, 10) - assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today)) .isEqualTo(LocalDate(2026, 7, 1)) - assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today)) .isEqualTo(LocalDate(2025, 6, 1)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 2cddeae4..a38c2588 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -163,6 +163,18 @@ class MonthLayoutTest { assertThat(week.spans.map { it.lane }).containsExactly(0, 1) } + @Test + fun `multi-day bar is above a one-day all-day bar on the same date`() { + val day = LocalDate(2026, 6, 10) + val single = allDay(day, id = 1L, title = "Single") + val multi = allDay(day, day.plus(2, DateTimeUnit.DAY), id = 2L, title = "Multi") + val week = layoutCalendarWeek(weekOf8th, listOf(single, multi), zone) + val lanes = week.spans.associate { it.event.title to it.lane } + assertThat(lanes.getValue("Multi")).isLessThan(lanes.getValue("Single")) + assertThat(instancesByDay(listOf(day), listOf(single, multi), zone).getValue(day).map { it.title }) + .containsExactly("Multi", "Single").inOrder() + } + @Test fun `single-day timed events stay pills and sort by start`() { val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) 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 00000000..9dd729c7 --- /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 00000000..96e5025b --- /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 2ac6c634..0480c9f7 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 6431da65..f444241a 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,15 +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. -The views themselves switch inside a `SharedTransitionLayout`: an event shown -in both morphs from its old bounds to its new ones, keyed per occurrence *and* -per day it is drawn from (`ViewMorphKey`), and only a pager's settled page -carries the tags. Overlays leave through floret-kit's `predictiveBackExit`, so -a committed back gesture finishes from its scaled preview. +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.