Compare commits

...
Author SHA1 Message Date
makiolaj 422904c9b9 Merge 2.22.0 work into view morph (#184) 2026-09-30 13:57:59 +02:00
makiolaj 51b9c56c3c Refine view switching and timeline morph (#184) 2026-09-30 13:28:47 +02:00
Jean-Luc Makiola 2af0f44fbe Merge consistent event ordering (#291) 2026-09-30 13:27:27 +02:00
makiolaj 7492888b1a Prioritize multi-day events across calendar views (#291) 2026-09-30 13:26:29 +02:00
Jean-Luc Makiola 17223cf0bb Merge week-number navigation (#197) 2026-09-30 13:24:26 +02:00
makiolaj 21c770afb3 Open week view from month week numbers (#197) 2026-09-30 13:23:37 +02:00
Jean-Luc Makiola aee23dc751 Merge today highlight in month views (#241) 2026-09-30 13:19:51 +02:00
makiolaj 705835a66f Highlight today in the month grid (#241) 2026-09-30 13:19:05 +02:00
Jean-Luc Makiola c2302c033e Merge split day list in Pages month view (#111) 2026-09-30 13:16:27 +02:00
makiolaj 596f76bbaa Open split day list from Pages month style (#111) 2026-09-30 13:15:32 +02:00
Jean-Luc Makiola 85f77f2332 Merge full month agenda in split view (#142) 2026-09-30 13:12:22 +02:00
makiolaj d471255340 Show month agenda by default in split view (#142) 2026-09-30 13:11:21 +02:00
Jean-Luc Makiola dcb1e891f1 Merge localized launcher name (#251) 2026-09-30 13:01:55 +02:00
Jean-Luc Makiola 7bf41fa0f8 Merge relative all-day reminder dates (#308) 2026-09-30 13:00:15 +02:00
Jean-Luc Makiola a23ea81c48 Merge out-of-view indicators (#188) 2026-09-30 12:57:42 +02:00
Jean-Luc Makiola 52aa48fda7 Merge visible time range (#322) 2026-09-30 12:53:29 +02:00
Jean-Luc Makiola 264b1ebbf4 Merge month paging and multi-day view (#193, #336) 2026-09-30 12:50:30 +02:00
makiolaj 62ea5ce9d3 Share one top bar across views and smooth the view switches (#184) 2026-09-27 21:12:22 +02:00
makiolaj 254fafe42b Document how the views switch (#184) 2026-09-27 19:31:59 +02:00
makiolaj f9dd7a22b7 Hold the top bar and FAB still across view switches (#184) 2026-09-27 19:28:10 +02:00
makiolaj 7d7d16ca31 Resize timeline columns in place when switching views (#184) 2026-09-27 19:26:24 +02:00
makiolaj 6ed0b30230 Lay timeline columns out through one geometry (#184) 2026-09-27 19:22:09 +02:00
makiolaj 8d3d28c240 Share one focused date across views (#184) 2026-09-27 19:18:13 +02:00
makiolaj f9738b0c23 Put the day view on the paged timeline (#184) 2026-09-27 19:13:47 +02:00
makiolaj 39ca7d869a Carry the timeline hours between views (#184) 2026-09-27 19:10:49 +02:00
makiolaj 57dcda5b54 Spring overlays back after a cancelled back gesture (#184) 2026-09-27 12:11:10 +02:00
makiolaj df33eca5f2 Reset the back preview when an overlay reopens (#184) 2026-09-27 12:06:45 +02:00
makiolaj dc841a91e0 Remember the view morph scope across recompositions (#184) 2026-09-27 12:01:41 +02:00
makiolaj f7805cb0b5 Morph events between views instead of fading (#184) 2026-09-27 11:21:32 +02:00
makiolaj 33c61202d9 Add a multi-day view with a configurable day count (#193) 2026-09-25 21:25:17 +02:00
makiolaj a2b6793406 Page the month view with a pager that follows the finger (#336)
Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
2026-09-25 14:40:04 +02:00
makiolaj eda616b42e Cancel dropped page states and restore the failure catch (#336)
Evicted week and day states kept their sharing coroutine, and their last
loaded events, alive until the view was closed; each now shares on its
own job, cancelled when it is let go.
2026-09-25 14:35:50 +02:00
makiolaj 7c17bf2ff4 Page the day view with a pager that follows the finger (#336)
Same model as the week view: days slide with the finger and snap past half
a page or on a fling, the hour gutter stays put, and the neighbouring days
are loaded ahead. The strip-height blend moves to common so both views
share it.
2026-09-25 13:41:28 +02:00
58 changed files with 2773 additions and 2279 deletions
+13
View File
@@ -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
@@ -288,6 +288,15 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
}
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
}
suspend fun setMultiDayCount(count: Int) {
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
}
/**
* The hours the day, week and multi-day timelines fit to the screen when they
* open (#322). Off by default; the hours survive switching it off.
@@ -309,15 +318,6 @@ class SettingsPrefs @Inject constructor(
}
}
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
}
suspend fun setMultiDayCount(count: Int) {
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
}
/**
* Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+"
@@ -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<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -3,22 +3,36 @@ package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,18 +40,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.calendula.ui.common.StayingPut
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
@@ -45,8 +79,8 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.MultiDayScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -54,8 +88,9 @@ import kotlin.time.Clock
/**
* Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
* screens. Each screen owns its own ViewModel; they all open on the one focused
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
* writes back here via [onSelectView].
*
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
@@ -72,6 +107,7 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CalendarHost(
modifier: Modifier = Modifier,
@@ -103,14 +139,33 @@ fun CalendarHost(
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
var navigationVersion by remember { mutableIntStateOf(0) }
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// The timeline view last shown, so the timeline leaving for another screen
// keeps drawing the view it was on while it goes.
var heldTimeline by rememberSaveable {
mutableStateOf(if (view.screen == ViewScreen.Timeline) view else CalendarView.Week)
}
val timelineView = if (view.screen == ViewScreen.Timeline) view else heldTimeline
SideEffect { heldTimeline = timelineView }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
// The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> 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
@@ -260,13 +315,14 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -290,7 +346,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
focus.navigateTo(localToday())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
@@ -341,6 +397,9 @@ fun CalendarHost(
}
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -349,87 +408,129 @@ fun CalendarHost(
editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
navigationVersion++
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) {
// Switching between the peer views (month/week/day/agenda) is lateral
// Switching between the month, timeline and agenda screens is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
// view keeps the directional slide. What both views show morphs across
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
val viewSwitch = fadeThrough()
val reduceMotion = rememberReduceMotion()
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() }
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
CompositionLocalProvider(
LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true },
LocalManageCalendars provides remember { { showCalendars = true } },
) {
// One drawer, top bar and FAB for every screen (#184): a switch changes
// what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button where edge-swipe would fight paging;
// the agenda has no paging to fight.
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
drawerContent = {
CalendarDrawer(
currentView = view,
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { target ->
// Once the drawer is out of the way, so the switch is seen whole.
val requestVersion = ++navigationVersion
switchScope.launch {
drawerState.close()
if (navigationVersion == requestVersion) onSelectView(target)
}
},
onJumpToDate = { target ->
chromeHost.actions?.onJumpToDate?.invoke(target)
switchScope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
switchScope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
topBar = {
CalendarTopBar(
host = chromeHost,
selectedView = view,
quickSwitchViews = quickSwitchViews,
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
onOpenDrawer = { switchScope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
) { innerPadding ->
SharedTransitionLayout(
Modifier
.padding(innerPadding)
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
// Keyed on the screen, not the view: the day, multi-day and week views
// are one timeline that resizes between them itself (#184), so switching
// among them must not replay this transition.
AnimatedContent(
targetState = view,
targetState = view.screen,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
) { screen ->
val sharedScope = this@SharedTransitionLayout
val visibilityScope = this@AnimatedContent
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
}
CompositionLocalProvider(
LocalViewMorph provides viewMorph,
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
) {
when (screen) {
ViewScreen.Timeline -> TimelineScreen(
view = timelineView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
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,
)
}
}
}
}
}
}
}
@@ -442,11 +543,12 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SearchScreen(
onBack = { showSearch = false },
onEventClick = onEventClick,
visible = showSearch,
)
}
@@ -455,7 +557,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
activeKey?.let { key ->
EventDetailScreen(
@@ -475,6 +577,7 @@ fun CalendarHost(
importForm = form
detailKey = null
},
visible = detailKey != null,
)
}
}
@@ -483,7 +586,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
@@ -492,6 +595,7 @@ fun CalendarHost(
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
)
}
}
@@ -500,7 +604,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen(
@@ -512,6 +616,7 @@ fun CalendarHost(
detailKey = null
},
onManageCalendars = { showCalendars = true },
visible = editKey != null,
)
}
}
@@ -520,12 +625,13 @@ fun CalendarHost(
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SettingsScreen(
onBack = { showSettings = false },
onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true },
visible = showSettings,
)
}
@@ -577,11 +683,12 @@ fun CalendarHost(
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
CalendarsScreen(
onBack = { showCalendars = false },
onOpenBackup = { showBackup = true },
visible = showCalendars,
)
}
@@ -589,7 +696,7 @@ fun CalendarHost(
AnimatedVisibility(
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
@@ -616,6 +723,7 @@ fun CalendarHost(
showBackup = false
showCalendars = false
},
visible = showBackup,
)
}
}
@@ -627,3 +735,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, 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
@@ -1,5 +1,18 @@
package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -18,34 +31,22 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -81,26 +77,22 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
// Opens on the focused date and hands back the day scrolled to the top.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -124,112 +116,88 @@ fun AgendaScreen(
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = {
val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier,
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
PublishChrome(
ScreenChrome(
title = stringResource(R.string.view_agenda),
titleDate = null,
drawerDate = anchor,
awayFromToday = !isOnToday || (scrolledAway && successState != null),
todayText = stringResource(R.string.agenda_today_action),
),
ScreenActions(
onToday = jumpToToday,
onJumpToDate = { target ->
focus?.date = target
viewModel.goToDate(target)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
onCreate = { onCreateEvent(anchor, null) },
),
)
ScreenBody(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
}
@@ -326,6 +294,7 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onTopDay: ((LocalDate) -> Unit)? = null,
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
@@ -368,6 +337,7 @@ internal fun AgendaContent(
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier,
)
}
@@ -386,8 +356,20 @@ private fun AgendaList(
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier,
) {
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn(
modifier = modifier,
state = listState,
@@ -420,7 +402,7 @@ private fun AgendaList(
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
@@ -430,6 +412,13 @@ private fun AgendaList(
}
}
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column(
@@ -451,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
@@ -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<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
)
}
}
@@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen(
onBack: () -> Unit,
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val state by viewModel.backupState.collectAsStateWithLifecycle()
@@ -137,6 +138,7 @@ fun BackupScreen(
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
when (val s = state) {
BackupUiState.Loading -> BackupLoading()
@@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen(
onBack: () -> Unit,
onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -177,6 +178,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup,
onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
@@ -193,6 +195,7 @@ private fun CalendarsList(
onConsumeError: () -> Unit,
onOpenBackup: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
@@ -218,6 +221,7 @@ private fun CalendarsList(
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint))
@@ -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<Dp> = 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<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val shareSpec: FiniteAnimationSpec<Float> = 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)
}
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
* host draws once for all of them (#184): switching screens changes what they
* say rather than swapping one bar for another.
*
* @param titleDate the date the title's picker opens on, or null for a title
* that is only a label.
* @param drawerDate the date the drawer's picker opens on.
* @param awayFromToday whether "today" is worth offering.
*/
@Immutable
data class ScreenChrome(
val title: String,
val shortTitle: String = title,
val titleDate: LocalDate?,
val drawerDate: LocalDate,
val awayFromToday: Boolean,
val todayText: String,
)
/** What the chrome does for the screen on show; read when used, so not observed. */
class ScreenActions(
val onToday: () -> Unit,
val onJumpToDate: (LocalDate) -> Unit,
val onCreate: () -> Unit,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var actions: ScreenActions? = null
private set
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
this.chrome = chrome
this.actions = actions
}
}
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
class ChromeSlot(val host: ChromeHost, val current: Boolean)
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
@Composable
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
val slot = LocalChromeSlot.current ?: return
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
title = {
if (chrome != null) {
CalendarTitleButton(
title = chrome.title,
currentDate = chrome.titleDate,
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
shortTitle = chrome.shortTitle,
)
}
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/** The FAB column every calendar screen shares. */
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
}
/**
* A calendar screen's body under the shared chrome, on the ground the screen's
* own Scaffold used to give it; the host's Scaffold has already made room for
* the bars, so [content] gets no padding of its own.
*/
@Composable
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
content(PaddingValues())
}
}
@@ -1,5 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common
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 = {
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -52,3 +53,19 @@ fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* A switch between views (#184): the default spatial spring, a little softer, as
* the whole screen reshapes rather than a page sliding by; softer still when
* [long], for a change the eye has further to follow.
*/
@Composable
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
spring(
dampingRatio = VIEW_SWITCH_DAMPING,
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
)
private const val VIEW_SWITCH_DAMPING = 0.9f
private const val VIEW_SWITCH_STIFFNESS = 450f
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
@@ -129,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
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<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* 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,
)
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
/**
* Where 00:00 sat in the last timeline shown, measured from the top of its
* view's content, so the next timeline view opens on the same hours (#184): an
* event in both keeps its place and only grows sideways.
*/
class TimelineScrollMemory {
var midnightY: Float? = null
}
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
/**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
* its view's content top. Opens where the last timeline left off, or on noon the
* first time.
*/
@Composable
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val memory = LocalTimelineScroll.current
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
)
val scroll = remember(scrollState) {
TimelineScroll(
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
)
}
val currentTop by rememberUpdatedState(topPx)
var firstOpen by remember(scrollState) { mutableStateOf(true) }
LaunchedEffect(scrollState, zoom, zoom.hours) {
if (firstOpen && remembered != null) {
firstOpen = false
return@LaunchedEffect
}
firstOpen = false
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2)
} else {
val (target, hourHeight) = snapshotFlow {
val viewport = scrollState.viewportSize
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourPx = with(density) { hourHeight.toPx() }
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
}.filterNotNull().first()
scrollState.scrollTo(target)
zoom.holdFit(hourHeight)
}
scroll.noonPending = false
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scroll
}
@@ -13,15 +13,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -285,31 +281,3 @@ internal fun anchoredScroll(
* as a scroll stays one.
*/
private const val PINCH_SLOP = 0.08f
/**
* Where a timeline opens (#322): scrolled to the start of the visible hours when
* they are on, otherwise centred on noon. Re-runs when the visible-hours
* preference changes, so a range picked in Settings shows on the way back.
*/
@Composable
fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) {
val density = LocalDensity.current
LaunchedEffect(zoom, zoom.hours) {
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours,
// so centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
} else {
val (target, hourHeight) = snapshotFlow {
val viewport = scrollState.viewportSize
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
val hourPx = with(density) { hourHeight.toPx() }
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
}.filterNotNull().first()
scrollState.scrollTo(target)
zoom.holdFit(hourHeight)
}
}
}
@@ -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(
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.LocalDate
/**
* The date every calendar view is looking at (#184), so switching views keeps
* the same days on screen and what is shown in both can travel between them.
*
* A view reads it once, as it comes on screen, and writes it back only when the
* user moves: a view settling where it opened must not nudge it, or switching
* back and forth would drift.
*/
@Stable
class ViewFocus(initial: LocalDate) {
var date by mutableStateOf(initial)
/** Explicit navigation, including a request for the view already on screen. */
var navigationVersion by mutableStateOf(0)
private set
fun navigateTo(target: LocalDate) {
date = target
navigationVersion++
}
}
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
save = { it.date.toString() },
restore = { ViewFocus(LocalDate.parse(it)) },
)
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
/**
* Runs [enter] with the focused date once, as the view comes on screen, before
* it reads its own position: its first frame is then already on that date.
*/
@Composable
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
val focus = LocalViewFocus.current
remember(key) { focus?.date?.also(enter) }
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What the views share when switching between them (#184): an event visible in
* both grows from where it sat into where it lands, a date label travels to its
* new seat, and everything else fades through.
*
* An element is tagged with the day it is drawn *from*, not just its instance: a
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
Event(event.eventId, event.start.toEpochMilliseconds(), day)
}
}
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
@OptIn(ExperimentalSharedTransitionApi::class)
class ViewMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: BelowTopBarClip,
val stayingPut: StayingPut,
)
/**
* The events a view has but keeps out of the switch, being scrolled out of
* sight: the other view leaves them untagged too, or its half would travel to
* nowhere and snap into place at the end.
*/
class StayingPut {
val keys = mutableStateSetOf<ViewMorphKey.Event>()
}
/** Marks [key] as staying put while [stays], for as long as it is composed. */
@Composable
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
if (!stays) return
DisposableEffect(stayingPut, key) {
stayingPut.keys += key
onDispose { stayingPut.keys -= key }
}
}
/**
* Keeps what travels below the top bar, which every view has at the same height:
* the overlay draws above the whole screen, so an event scrolled under the bar
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
var top = 0f
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
private companion object {
const val FAR = 1e6f
}
}
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
* Whether a pager page may carry the morph tags: only the settled one. Its
* neighbours are composed ahead and repeat some of its dates, which would both
* claim its keys and fly in from off screen.
*/
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
@Composable
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
val morph = LocalViewMorph.current
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
}
/**
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
* real size and is clipped while there is no room, instead of smearing.
*
* Goes after the element's offset and before its size, so the travelling bounds
* drive the measurement.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this
if (key in morph.stayingPut.keys) return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's number, which is small on both sides, so it scales on its way. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
zIndexInOverlay = 1f,
)
}
}
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = rememberViewSwitchSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
@@ -1,12 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common
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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,25 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}
@@ -1,136 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
import kotlin.math.abs
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
private const val DAY_CACHE_DAYS = 7
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
/** The pager page the anchor day sits on. */
val anchorPage: StateFlow<Int> = _date
.map { dayPageFor(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = dayPageFor(todayDate),
)
/**
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
* moving the anchor keeps the last result until the new day arrives, since
* the pages draw their own days and this only gates the failure screen.
*/
val state: StateFlow<DayUiState> = _date
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
private val days = PageStateCache<LocalDate, DayUiState>(
scope = viewModelScope,
initial = DayUiState.Loading,
// Far enough to cover the pages either side and a swipe back, so paging
// through a month doesn't keep a month of queries around.
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
) { date ->
combine(
repository.calendars(),
repository.instances(dayRange(date, zone)),
) { calendars, instances ->
buildState(date, calendars, instances)
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
}
/** The day [date], shared between its pager page and [state]. Main thread only. */
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
fun onPageSettled(page: Int) {
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
}
fun goToToday() {
_date.value = todayDate
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -1,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
* real; only the events are stand-ins.
*/
@Composable
internal fun DayViewPreview(
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, zone) { sampleDayState(today, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample day state, laid out through the same helpers the live view uses. */
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
val days = listOf(today)
val events = sampleTimelineEvents(days, today, zone)
return DayUiState.Success(
date = today,
today = today,
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timed = layoutDay(events, today, zone),
)
}
@@ -140,6 +140,7 @@ fun EventDetailScreen(
onBack: () -> Unit,
onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit,
visible: Boolean = true,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -227,7 +228,7 @@ fun EventDetailScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -203,6 +203,7 @@ fun EventEditScreen(
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -285,7 +286,7 @@ fun EventEditScreen(
}
Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
@@ -78,6 +78,7 @@ fun ImportScreen(
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: ImportViewModel = hiltViewModel(),
) {
// hiltViewModel() resolves to the Activity's store and is retained across
@@ -103,7 +104,7 @@ fun ImportScreen(
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.predictiveBack(onBack = onClose, visible = visible)
.fillMaxSize(),
topBar = {
TopAppBar(
@@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* what the *recipient of the binary* is told. Each row opens the project's source.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit) {
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
Text(
stringResource(R.string.licences_intro),
File diff suppressed because it is too large Load Diff
@@ -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<LocalDate> = _selectedDate
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
val selectedDate: StateFlow<LocalDate?> = _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<EventInstance> { 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 {
@@ -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)
}
}
@@ -126,6 +126,7 @@ fun SearchScreen(
onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsStateWithLifecycle()
@@ -184,7 +185,7 @@ fun SearchScreen(
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold(
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
SearchTopBar(
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Theme & colour
GroupedRow(
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showDefaultDuration by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any.
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
title = stringResource(R.string.settings_section_event_form),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
onOpenSpecialDates: () -> Unit,
visible: Boolean = true,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
title = stringResource(R.string.settings_section_notifications),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */
@@ -85,11 +84,14 @@ fun SettingsScreen(
onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit,
modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec()
// Leaves from the predictive-back preview rather than sliding off at full size.
val overlayExit = predictiveBackExit()
Box(
modifier = modifier
@@ -101,61 +103,63 @@ fun SettingsScreen(
onOpenSection = { section = it },
onManageCalendars = onManageCalendars,
onOpenBackup = onOpenBackup,
visible = visible,
)
AnimatedVisibility(
visible = section == SettingsSection.Appearance,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
}
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
}
AnimatedVisibility(
visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
}
AnimatedVisibility(
visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
NotificationsScreen(
state = state,
viewModel = viewModel,
onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
visible = section == SettingsSection.Notifications,
)
}
AnimatedVisibility(
visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
}
AnimatedVisibility(
visible = section == SettingsSection.Widgets,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
exit = overlayExit,
) {
LicencesScreen(onBack = { section = null })
LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
}
}
}
@@ -170,8 +174,14 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit,
visible: Boolean,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
CollapsingScaffold(
title = stringResource(R.string.settings_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Card and support row are one grouped block, so the call to action
// continues the container instead of sitting inside it as a button.
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
internal fun SpecialDatesScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val settings by viewModel.state.collectAsStateWithLifecycle()
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
title = stringResource(R.string.settings_section_special_dates),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) {
@@ -68,6 +68,7 @@ internal fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
@@ -85,6 +86,7 @@ internal fun ViewsScreen(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val config = state.quickSwitchConfig
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlin.math.roundToInt
/** A day column's place across the timeline, in pixels from its start edge. */
@Immutable
data class ColumnSlot(val left: Float, val width: Float) {
val right: Float get() = left + width
}
/**
* [date]'s column on a page showing this span across [width] pixels, with [gap]
* between columns. A date off the page carries on at the same pitch, off screen
* on its side — where a view switch slides it in from, or out to.
*/
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val pitch = (width + gap) / count
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
}
/** The last day of the span. */
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
/**
* The timeline's columns: one page's, or — while switching between the day, the
* multi-day and the week view (#184) — every day of both pages, each column
* between where it sits in the one and where it sits in the other.
*
* @param progress how far from [from] to [to], read at layout time only.
*/
@Stable
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
val dates: List<LocalDate> = run {
val first = minOf(from.start, to.start)
val last = maxOf(from.end, to.end)
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
}
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val a = from.slot(date, width, gap)
if (from == to) return a
val b = to.slot(date, width, gap)
val p = progress()
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
}
/** The span of columns [range] covers, between its extent on each page. */
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val a = from.extent(fromRange, width, gap)
if (from == to && fromRange == toRange) return a
val b = to.extent(toRange, width, gap)
val p = progress()
val left = lerp(a.left, b.left, p)
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
}
companion object {
/** One page's columns, standing still. */
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
}
}
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val left = slot(range.start, width, gap).left
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
}
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
/**
* Lays [content] out once per day of [geometry], each across its column. The
* columns are placed in the layout pass, so a view switch moving them every
* frame re-lays them out without recomposing them. Children fill the height
* the layout is given, or wrap it when it has none.
*/
@Composable
fun TimelineColumns(
geometry: ColumnGeometry,
gap: Dp,
modifier: Modifier = Modifier,
content: @Composable (LocalDate) -> Unit,
) {
val dates = geometry.dates
Layout(
content = {
dates.forEach { date ->
key(date) { Box(propagateMinConstraints = true) { content(date) } }
}
},
modifier = modifier,
) { measurables, constraints ->
val width = constraints.maxWidth
val gapPx = gap.toPx()
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
val placeables = measurables.mapIndexed { i, measurable ->
val left = slots[i].left.roundToInt()
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
}
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
?: constraints.minHeight
layout(width, height) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(slots[i].left.roundToInt(), 0)
}
}
}
}
/**
* An all-day bar as each page lays it out: the days it covers there, clipped to
* the page, and its lane. A bar only one page has keeps its days on the other,
* so it slides out with them rather than vanishing.
*/
@Immutable
data class PlacedBar(
val event: EventInstance,
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
val keyDate: LocalDate,
val fromRange: ClosedRange<LocalDate>,
val toRange: ClosedRange<LocalDate>,
val fromLane: Int,
val toLane: Int,
)
/** [state]'s all-day bars, standing still. */
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
state.allDaySpans.map { span ->
val range = state.days[span.startCol]..state.days[span.endCol]
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
}
/**
* The all-day bars of both pages of a view switch, each once: matched by
* occurrence, placed on each page as that page lays it out.
*/
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
return (fromBars.keys + toBars.keys).map { occurrence ->
val a = fromBars[occurrence]
val b = toBars[occurrence]
PlacedBar(
event = (a ?: b)!!.event,
keyDate = (a ?: b)!!.keyDate,
fromRange = (a ?: b)!!.fromRange,
toRange = (b ?: a)!!.toRange,
fromLane = (a ?: b)!!.fromLane,
toLane = (b ?: a)!!.toLane,
)
}
}
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.core.Animatable
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
/**
* A switch between two timeline views in progress (#184): the page on screen
* when it started, the page it ends on, and how far between the two it is.
*/
@Stable
internal class TimelineMorph(
val fromKind: TimelineKind,
val toKind: TimelineKind,
val from: DaySpan,
val to: DaySpan,
val fromState: StateFlow<WeekUiState>,
val toState: StateFlow<WeekUiState>,
) {
val progress = Animatable(0f)
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
/** Where each day's events come from: the page it was on, else the page it goes to. */
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
enum class Side { From, To }
}
private const val LONG_SWITCH_RATIO = 3
@@ -73,3 +73,35 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
}
}
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val DAY_EPOCH = LocalDate(2000, 1, 1)
/** Single days: every date starts a page of its own. */
data object DayGrid : PageGrid {
override val dayCount: Int get() = 1
override val pageCount: Int get() = DAY_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
}
/**
* Where the focused date goes when the pager settles on [page] (#184): kept if
* the page still shows it, otherwise moved by the pages travelled, so it keeps
* its place in the page — the same weekday a week on.
*/
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
val start = startForPage(page)
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
if (focus in start..end) return focus
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
}
File diff suppressed because it is too large Load Diff
@@ -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<LocalDate> = _anchor
val position: StateFlow<PagerPosition> =
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
/**
* The grid for a given anchor, held eagerly so a screen can place its pager
* on its very first frame rather than a frame after the anchor moved.
*/
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
.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<Raw> { 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
@@ -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(),
+3 -1
View File
@@ -54,7 +54,9 @@
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</string>
<string name="month_open_week">Open week %1$d</string>
<string name="month_today_action">Today</string>
<string name="month_split_clear_filter">Show full month agenda</string>
<string name="month_more_actions">More actions</string>
<string name="month_open_menu">Open menu</string>
<string name="month_action_settings">Settings</string>
@@ -550,7 +552,7 @@
<string name="settings_month_header">Month view</string>
<string name="settings_month_view_style">Month view style</string>
<string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda.</string>
<string name="month_style_continuous">Scrolling months</string>
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
<string name="month_style_dense">Seamless weeks</string>
@@ -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)
}
}
@@ -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))
@@ -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.
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ViewMorphKeyTest {
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
instanceId = instanceId,
eventId = eventId,
calendarId = 1,
title = "Standup",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false,
color = 0,
location = null,
)
private val day = LocalDate(2026, 9, 25)
@Test
fun `the same occurrence on the same day matches across views`() {
// Each view reads its own instances query, so the row id is no anchor.
assertThat(ViewMorphKey.event(instance(instanceId = 1), day))
.isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day))
}
@Test
fun `a multi-day event keys each day it is drawn from apart`() {
assertThat(ViewMorphKey.event(instance(1), day))
.isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26)))
}
@Test
fun `two occurrences of one series stay apart`() {
assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day))
.isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day))
}
@Test
fun `only the settled page morphs`() {
assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue()
assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse()
assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse()
}
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** Which screen draws each view: the timeline views share one, so switching among them resizes it (#184). */
class ViewScreenTest {
@Test
fun `the day, multi-day and week views share the timeline`() {
assertThat(listOf(CalendarView.Day, CalendarView.MultiDay, CalendarView.Week).map { it.screen }.toSet())
.containsExactly(ViewScreen.Timeline)
}
@Test
fun `month and agenda each have their own screen`() {
assertThat(CalendarView.Month.screen).isEqualTo(ViewScreen.Month)
assertThat(CalendarView.Agenda.screen).isEqualTo(ViewScreen.Agenda)
}
}
@@ -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))
}
}
@@ -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)
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The timeline's columns, standing still and mid view switch (#184). */
class ColumnGeometryTest {
// 2026-09-27 is a Sunday; the week runs Sun..Sat.
private val sunday = LocalDate(2026, 9, 27)
private val week = DaySpan(sunday, 7)
private val monday = sunday.plus(1, DateTimeUnit.DAY)
private val day = DaySpan(monday, 1)
private val width = 700f
private val gap = 7f
@Test
fun `a page's columns follow the pitch`() {
val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap)
assertThat(slot.left).isEqualTo(3 * 101f)
assertThat(slot.width).isEqualTo(94f)
}
@Test
fun `the ends of a switch are the two pages`() {
var p = 0f
val geometry = ColumnGeometry(week, day) { p }
assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap))
p = 1f
assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap))
}
@Test
fun `days off the target page end off screen on their side`() {
val geometry = ColumnGeometry(week, day) { 1f }
assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f)
assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width)
}
@Test
fun `a switch covers both pages without a break`() {
// Focus on Saturday: the six-day run starting there reaches into next week.
val saturday = sunday.plus(6, DateTimeUnit.DAY)
val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f }
assertThat(geometry.dates).hasSize(12)
assertThat(geometry.dates.first()).isEqualTo(sunday)
assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue()
}
@Test
fun `a bar spans its extent on each page`() {
val tuesday = monday.plus(1, DateTimeUnit.DAY)
val range = monday..tuesday
val geometry = ColumnGeometry(week, day) { 0f }
val extent = geometry.extent(range, monday..monday, width, 0f)
assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left)
assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right)
}
@Test
fun `a bar only one page has keeps its days on the other`() {
val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1)
val from = state(week, listOf(bar))
val to = state(day, emptyList())
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(placed.toRange)
assertThat(placed.toLane).isEqualTo(1)
}
@Test
fun `a bar on both pages is placed once, clipped to each`() {
val event = event(1)
val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0)))
val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0)))
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY))
assertThat(placed.toRange).isEqualTo(monday..monday)
}
private fun state(span: DaySpan, bars: List<AllDaySpan>): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = span.start,
today = sunday,
days = days,
allDaySpans = bars,
timedByDay = emptyMap(),
)
}
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = true,
color = 0xFF000000.toInt(),
location = null,
)
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Where the focused date goes as a timeline pages (#184). */
class FocusOnPageTest {
// 2026-09-30 is a Wednesday.
private val wednesday = LocalDate(2026, 9, 30)
@Test
fun `a page that still shows the focus keeps it`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday)
}
@Test
fun `a week on keeps the weekday`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7))
assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16))
}
@Test
fun `a run of days keeps the focus's column`() {
val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3))
}
@Test
fun `a day page is the focus`() {
val page = DayGrid.pageFor(wednesday)
assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5))
}
}
@@ -64,4 +64,18 @@ class WeekPagingTest {
assertThat(grid.startForPage(page)).isEqualTo(date)
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
}
@Test
fun `a day page and its day round-trip`() {
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day)
}
}
@Test
fun `adjacent day pages are adjacent days across a month end`() {
val page = DayGrid.pageFor(LocalDate(2026, 2, 28))
assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
}
}
+35 -3
View File
@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
```mermaid
flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"]
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"]
Screens["Month / Timeline / Agenda\nDetail / Edit / Settings\nOnboarding wizard"]
end
subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
@@ -79,10 +79,42 @@ flowchart TD
There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the first-launch wizard (`ui/onboarding/`), then shows
`CalendarHost`. `CalendarHost` holds the active view (month/week/day)
plus overlay state for detail, edit, and settings — full-screen overlays
`CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week,
agenda) plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty.
Overlays leave through floret-kit's `predictiveBackExit`, so a committed back
gesture finishes from its scaled preview.
### Switching views (#184)
- **One focused date.** `CalendarHost` owns a `ViewFocus` (`LocalViewFocus`)
that every view opens on and carries along. A view *reads* it once, on
entry, synchronously (`EnterOnFocus` before it reads its own position, so
its first frame is already on that date), and *writes* it only when the user
moves — a pager settling (`focusOnPage` keeps the date's place in the page),
a jump, Today, a tapped day, the agenda's top row. Writing on entry would
drift the date on every round trip.
- **Three screens, not five.** The host's `AnimatedContent` is keyed on
`ViewScreen` (month, timeline, agenda), not on the view: day, multi-day and
week are one `TimelineScreen` with a `TimelineKind` each and a view model
each. Keying the three on one `contentKey` instead would still replay the
enter transition on every switch among them.
- **Timeline ↔ timeline resizes in place.** `ColumnGeometry` places every
column (header cell, all-day bar, day column) in the layout pass; a switch
swaps the pager for a frame over both pages' days, each column lerped
between its slot on the one page and the other — a day the page doesn't
show carries on at its pitch, off screen, which is what slides it in or
out. Blocks sit in their column as shares of its width (`LaneColumn`), so
nothing recomposes per frame. The frame starts and ends exactly as the two
pages lay out, so the pagers either side hand over without a jump.
- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`,
an event shown in both views travels from its old bounds to its new ones,
keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a
pager's settled page carries the tags. The drawer, top bar and FAB are not
per screen: CalendarHost draws them once, above the switch, from what the
screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a
switch only changes what they say.
A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap.