diff --git a/CHANGELOG.md b/CHANGELOG.md index d10b846..49f8808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Choose how the month view is laid out. A new **Month view style** setting + (Settings → Views) offers three ways to read a month, each shown with a + preview of the layout it produces: + - **Pages** — what you have today: one month at a time, swiped sideways. + - **Continuous** — scroll up and down through the weeks without a break + between months. Because the weeks run on unbroken, no month is cut off and + no day appears twice, where paging repeats a boundary week at the end of one + month and the start of the next. The 1st of each month names itself so you + always know where you are, and the title bar keeps up as you scroll ([#38]). + - **Split** — a compact grid showing coloured dots for the days that have + something on them, with the day you tap listed in full underneath. Tap the + date above the list to open the whole day ([#53]). + + The Agenda view is untouched by this and stays available in all three styles — + the split layout lists a single day, while Agenda remains a rolling multi-day + window with its own range settings. + ### Fixed - The "Upcoming" agenda widget now scales its text and rows to the size you give it. Previously it was laid out once for the smallest size and simply stretched @@ -1057,5 +1075,7 @@ automatically, with zero telemetry and no internet permission. [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 [#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 +[#38]: https://codeberg.org/jlmakiola/calendula/issues/38 +[#53]: https://codeberg.org/jlmakiola/calendula/issues/53 [#60]: https://codeberg.org/jlmakiola/calendula/issues/60 [#65]: https://codeberg.org/jlmakiola/calendula/issues/65 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 316e33d..5020eb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import java.time.ZoneId import kotlinx.coroutines.CoroutineScope @@ -256,6 +257,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } } + /** + * How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged] + * — the historical behaviour, so existing installs see no change until they opt in. + */ + val monthViewStyle: Flow = store.data.map { prefs -> + prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged) + } + + suspend fun setMonthViewStyle(style: MonthViewStyle) { + store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } + } + /** * 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 "+" @@ -775,6 +788,7 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") + internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index cf5abea..b830f74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -338,6 +338,7 @@ fun CalendarHost( selectedView = currentView, onSelectView = onSelectView, onOpenDay = onOpenDay, + onEventClick = onEventClick, onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt new file mode 100644 index 0000000..9aedc1c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -0,0 +1,206 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Coffee +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors +import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant +import java.util.Locale + +// The agenda's row vocabulary, split out of AgendaScreen so the month view's +// split style can list a day with exactly the same visual language instead of +// growing a parallel set of event rows. + +@Composable +internal fun AgendaDayHeader( + date: LocalDate, + today: LocalDate, + onOpenDay: (LocalDate) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenDay(date) }, + ) { + Text( + text = agendaDayLabel(date, today), + style = MaterialTheme.typography.titleSmall, + color = if (date == today) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +/** + * A card standing in for a day with no events — the same coffee-cup motif as the + * agenda's full-screen empty state, boxed into a card so the day keeps a visible slot + * rather than a bare header. Used for an anchored, event-less today (#35) and for + * an empty selected day in the month view's split style. + */ +@Composable +internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) { + Card( + // Match a single event row's resting corner radius (floret groupedShape). + shape = RoundedCornerShape(22.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.Coffee, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(36.dp), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +internal fun AgendaEventRow( + event: EventInstance, + day: LocalDate, + zone: TimeZone, + position: Position, + dimmed: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val soften = LocalSoftenColors.current + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + GroupedRow( + modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, + title = title, + summary = agendaTimeSummary(event, day, zone), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(eventFill(event.color, dark, soften)), + ) + }, + onClick = onClick, + ) +} + +/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ +@Composable +internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String { + val relative = when (date) { + today -> stringResource(R.string.agenda_header_today) + today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) + else -> null + } + val formatted = formatAgendaDate(date) + return if (relative != null) "$relative · $formatted" else formatted +} + +/** + * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. + * + * A multi-day event shows only the part relevant to [day], spelled out so each + * day reads on its own: its first day names the start ("Starts 14:00"), its last + * day the end ("Ends 10:00"), and any whole day in between reads as "All day". + * An all-day multi-day event is simply "All day" on every day it covers. + */ +@Composable +internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { + val is24Hour = LocalUse24HourFormat.current + val locale = currentLocale() + + val time = when (val label = agendaTimeLabel(event, day, zone)) { + AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> stringResource( + R.string.agenda_span_starts, + formatTime(label.start, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Ends -> stringResource( + R.string.agenda_span_ends, + formatTime(label.end, zone, is24Hour, locale), + ) + is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + + formatTime(label.end, zone, is24Hour, locale) + } + + val location = event.location?.takeIf { it.isNotBlank() } + return if (location != null) "$time · $location" else time +} + +private fun formatTime( + instant: Instant, + zone: TimeZone, + is24Hour: Boolean, + locale: Locale, +): String { + val t = instant.toLocalDateTime(zone).time + return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) +} + +private fun formatAgendaDate(date: LocalDate): String { + val locale = Locale.getDefault() + val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) + // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" + // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. + return localizedDateFormatter(locale, "EEEdMMMy").format(java) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index f259929..83006f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,9 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape 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.Card import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -59,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker -import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors -import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer @@ -69,25 +59,15 @@ 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.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.Position 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 -import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import kotlinx.coroutines.launch -import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant -import java.util.Locale // No file-level zone constant here on purpose: it would be fixed for the process // lifetime and drift from the zone AgendaViewModel groups in after a device @@ -370,7 +350,10 @@ private fun AgendaList( if (day.events.isEmpty()) { // An anchored, event-less today (#35) — "nothing left today". item(key = "placeholder-${day.date}") { - AgendaEmptyDayRow(onClick = { onOpenDay(day.date) }) + AgendaEmptyDayRow( + text = stringResource(R.string.agenda_no_more_today), + onClick = { onOpenDay(day.date) }, + ) } } else { itemsIndexed( @@ -396,100 +379,6 @@ private fun AgendaList( } } -@Composable -private fun AgendaDayHeader( - date: LocalDate, - today: LocalDate, - onOpenDay: (LocalDate) -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .fillMaxWidth() - .clickable { onOpenDay(date) }, - ) { - Text( - text = agendaDayLabel(date, today), - style = MaterialTheme.typography.titleSmall, - color = if (date == today) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), - ) - } -} - -/** - * A card under an anchored, event-less today (#35) — the same coffee-cup motif - * as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a - * visible slot when nothing is left rather than a bare header. - */ -@Composable -private fun AgendaEmptyDayRow(onClick: () -> Unit) { - Card( - // Match a single event row's resting corner radius (floret groupedShape). - shape = RoundedCornerShape(22.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clickable(onClick = onClick), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 18.dp, horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - imageVector = Icons.Filled.Coffee, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(36.dp), - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.agenda_no_more_today), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun AgendaEventRow( - event: EventInstance, - day: LocalDate, - zone: TimeZone, - position: Position, - dimmed: Boolean, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current - val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - GroupedRow( - modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = title, - summary = agendaTimeSummary(event, day, zone), - position = position, - minHeight = 64.dp, - leading = { - Box( - modifier = Modifier - .size(width = 6.dp, height = 36.dp) - .clip(RoundedCornerShape(3.dp)) - .background(eventFill(event.color, dark, soften)), - ) - }, - onClick = onClick, - ) -} - @Composable private fun AgendaEmpty(modifier: Modifier = Modifier) { Column( @@ -559,64 +448,3 @@ private fun AgendaTopBar( scrollBehavior = scrollBehavior, ) } - -/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ -@Composable -private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { - val relative = when (date) { - today -> stringResource(R.string.agenda_header_today) - today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) - else -> null - } - val formatted = formatAgendaDate(date) - return if (relative != null) "$relative · $formatted" else formatted -} - -/** - * Time line under the title: "09:00 – 10:00 · Location", "All day", etc. - * - * A multi-day event shows only the part relevant to [day], spelled out so each - * day reads on its own: its first day names the start ("Starts 14:00"), its last - * day the end ("Ends 10:00"), and any whole day in between reads as "All day". - * An all-day multi-day event is simply "All day" on every day it covers. - */ -@Composable -private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String { - val is24Hour = LocalUse24HourFormat.current - val locale = currentLocale() - - val time = when (val label = agendaTimeLabel(event, day, zone)) { - AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> stringResource( - R.string.agenda_span_starts, - formatTime(label.start, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Ends -> stringResource( - R.string.agenda_span_ends, - formatTime(label.end, zone, is24Hour, locale), - ) - is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + - formatTime(label.end, zone, is24Hour, locale) - } - - val location = event.location?.takeIf { it.isNotBlank() } - return if (location != null) "$time · $location" else time -} - -private fun formatTime( - instant: Instant, - zone: TimeZone, - is24Hour: Boolean, - locale: Locale, -): String { - val t = instant.toLocalDateTime(zone).time - return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) -} - -private fun formatAgendaDate(date: LocalDate): String { - val locale = Locale.getDefault() - val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) - // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" - // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. - return localizedDateFormatter(locale, "EEEdMMMy").format(java) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt new file mode 100644 index 0000000..dc15d03 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSwipe.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +/** + * Drag distance that turns a calendar page, shared by the month, week and day + * views so all three answer a swipe at the same point. + * + * It was 6dp once, which is inside the distance a tap wanders: brushing the grid + * changed the month, and a page that turns on an unintended gesture reads as the + * animation misfiring rather than as the gesture being over-eager. + */ +val CALENDAR_SWIPE_THRESHOLD = 24.dp + +/** + * The whole-page horizontal swipe: one page per gesture, committed **the moment + * the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts. + * + * Waiting for the lift meant the page sat still under a finger that had already + * travelled far enough to ask for it, and the answer only arrived once you let + * go — which reads as the view being slow rather than as a deliberate commit. + * Firing on the threshold is what makes the gesture feel like it is being + * followed. The trade is that a drag can no longer be taken back by dragging the + * other way; in practice, once you have moved 24dp deliberately you meant it, and + * the page you land on is one swipe back. + * + * Deliberately **horizontal-only**. The week and day timelines scroll vertically + * underneath this, and a two-dimensional detector here would claim those drags + * before the inner scroll ever saw them. As it is, a horizontal drag crosses this + * detector's slop while a vertical one is consumed below, and the two coexist. + * (The month view's split style needs a vertical axis as well, so it keeps its + * own axis-locking detector rather than using this.) + */ +@Composable +fun rememberCalendarPageSwipe( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, +): Modifier { + val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() } + return Modifier.pointerInput(onSwipeNext, onSwipePrev) { + var accum = 0f + // One page per gesture: without this a long drag would keep re-firing + // every time the accumulator crossed the threshold again. + var fired = false + detectHorizontalDragGestures( + onDragStart = { + accum = 0f + fired = false + }, + onDragEnd = { + accum = 0f + fired = false + }, + onDragCancel = { + accum = 0f + fired = false + }, + onHorizontalDrag = { _, drag -> + accum += drag + if (!fired) { + val commit = when { + accum < -threshold -> onSwipeNext + accum > threshold -> onSwipePrev + else -> null + } + if (commit != null) { + fired = true + commit() + } + } + }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt index 54a5b7a..cf57422 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset */ /** - * The M3 Expressive spatial spring used for the month/week slide: the *fast* - * spring-physics spec from the active motion scheme — snappy with a subtle - * springy settle, rather than a fixed easing curve. + * The M3 Expressive spatial spring used for the month/week/day slide: the + * *default* spring-physics spec from the active motion scheme, rather than a + * fixed easing curve. + * + * Default rather than fast: `fastSpatialSpec` is tuned for small, incidental + * movement, and driving a whole calendar page with it made the settle read as a + * twitch instead of a glide. * * Read it in a composable scope (this helper) so it can be captured by the * non-composable `AnimatedContent` transitionSpec lambda. @@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberCalendarSlideSpec(): FiniteAnimationSpec = - MaterialTheme.motionScheme.fastSpatialSpec() + MaterialTheme.motionScheme.defaultSpatialSpec() /** - * The fast effects spec from the active motion scheme, for opacity (fade) - * transitions. Captured in composable scope alongside [rememberCalendarSlideSpec] - * for use in non-composable transition lambdas and as the reduced-motion fallback. + * The effects spec from the active motion scheme, for the opacity half of the + * transition. Captured in composable scope alongside [rememberCalendarSlideSpec] + * for use in non-composable transition lambdas, and reused on its own as the + * reduced-motion fallback. + * + * Opacity gets its own spec on purpose: M3 Expressive springs *position* and + * eases *opacity*, and a fade that bounced with the slide would shimmer. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun rememberCalendarFadeSpec(): FiniteAnimationSpec = - MaterialTheme.motionScheme.fastEffectsSpec() + MaterialTheme.motionScheme.defaultEffectsSpec() /** - * Horizontal slide for navigating between adjacent months/weeks/days. + * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the + * outgoing page slides and fades one way while the incoming one arrives from the + * other, position on a spring and opacity on an easing curve. * * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump * (e.g. "today"); a jump reuses the forward direction. * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. - * @param fadeSpec effects spec for the reduced-motion fade, typically + * @param fadeSpec effects spec for the opacity half, and for the whole + * transition under reduced motion; typically * [rememberCalendarFadeSpec]. - * @param reduceMotion when true, swap the directional slide for a plain cross-fade. + * @param reduceMotion when true, drop the movement and cross-fade alone. */ fun calendarSlideTransition( slideDir: Int, @@ -65,6 +77,26 @@ fun calendarSlideTransition( return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) } val dir = if (slideDir == 0) 1 else slideDir - return slideInHorizontally(spec) { w -> dir * w } - .togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) + return ContentTransform( + targetContentEnter = + slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec), + initialContentExit = + slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), + // AnimatedContent clips to the animating container by default, which + // shears the pages against the viewport edge as they pass. There is no + // size change here to contain — both pages are the same grid. + sizeTransform = SizeTransform(clip = false), + ) } + +/** + * How far a page travels, as a fraction of the container width. + * + * A full width was the obvious reading of "paging", but the two pages are + * stacked and both opaque, so a full-width slide showed one grid racing across + * another — the movement carried the whole transition and had a long way to go. + * Under M3's shared-axis pattern the offset only has to *hint* the direction + * while the cross-fade does the swapping, so a fifth of the width is plenty and + * leaves nothing skating past. + */ +private const val SLIDE_TRAVEL_DIVISOR = 5 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index c038dee..a5fcb2f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -198,7 +198,7 @@ private fun CustomReminderEditor( /** A short explanatory paragraph shown under a picker's title, above the rows. */ @Composable -private fun PickerDescription(text: String) { +internal fun PickerDescription(text: String) { Text( text = text, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 4526677..842486e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors @@ -266,8 +265,6 @@ private fun DayContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -296,20 +293,7 @@ private fun DayContent( // Whole-page horizontal swipe, one level above the timeline's vertical // scroll: a horizontal drag crosses this detector's slop, while a vertical // drag is consumed by the inner scroll first — the two gestures coexist. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt new file mode 100644 index 0000000..e46e36b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -0,0 +1,154 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalDate + +/** + * What lets the split style's compact grid *become* the full month rather than be + * swapped for it (#53). + * + * The two grids are separate composables — the compact one draws dots, the + * expanded one draws the paged style's bars and pills — so nothing about them is + * shared by construction. Tagging the pieces that mean the same thing on both + * sides with the same [MonthMorphKey] hands Compose enough to animate one into + * the other: a day's cell grows, its number rides along, and each dot travels out + * to the bar it stood for. + * + * It travels as a composition local rather than as parameters because the pieces + * sit five levels below where the scopes exist, and because a null default is + * exactly the right meaning for everyone else: the paged, continuous, and dense + * styles share the same row and cell composables, provide nothing, and pay + * nothing. Reduced motion provides nothing either, which leaves the plain + * cross-fade underneath. + */ +internal sealed interface MonthMorphKey { + /** A day's background pill — the structural anchor the rest rides on. */ + data class Cell(val date: LocalDate) : MonthMorphKey + + data class DayNumber(val date: LocalDate) : MonthMorphKey + + /** + * One event *on one day*. Keyed by date as well as instance because a + * multi-day event has a dot on every day it covers but only one bar, drawn + * from where it starts: the start day's dot is the one that becomes the bar, + * and the rest are left unmatched on purpose. They fade where they stand + * while the bar sweeps out over them, which is the honest reading — one of + * them could not become the bar without the others teleporting into it. + */ + data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey + + /** + * A day's "+N" marker. The compact grid writes it as a count beside the dots + * and the expanded one as a row of small dots under the bars, but they stand + * for the same events and appear on exactly the same days — both sides seat + * three and overflow the rest — so they are a matched pair, not two unrelated + * bits of text. Tagged, the marker travels with its day like everything else + * in the cell. + */ + data class Overflow(val date: LocalDate) : MonthMorphKey + + /** + * The selected day's outline. The expanded grid draws no outline — once the + * cells carry real event bars it is one mark too many — so this pairs the + * compact grid's outline with an *invisible* stand-in over the same cell + * there. Without one it had nowhere to travel and could only fade in place + * while everything around it moved. + */ + data class Selection(val date: LocalDate) : MonthMorphKey + + /** The grab handle, which slides from the pane's seam to the foot of the grid. */ + data object Handle : MonthMorphKey +} + +@OptIn(ExperimentalSharedTransitionApi::class) +internal class MonthMorphScope( + val shared: SharedTransitionScope, + val visibility: AnimatedVisibilityScope, +) + +internal val LocalMonthMorph = compositionLocalOf { null } + +/** + * True while the grid is mid-morph, for content that has to stop clipping to let + * the travelling pieces through. + * + * A mark's two homes are in *different rows*: the dot for the 20th sits a third + * of the way down the compact grid, its bar most of the way down the expanded + * one. Clipped to the row it is arriving at, a mark spends the first half of its + * journey outside those bounds and is simply not drawn — so it appears from + * nowhere, part-way through, already near its destination. Rendering in place + * rather than in an overlay is what subjects them to that clip, and is worth + * keeping; the clip is what has to yield, and only while pieces are in flight. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun morphInFlight(): Boolean { + val morph = LocalMonthMorph.current ?: return false + return morph.shared.isTransitionActive +} + +/** + * Tag content that is *the same thing* on both sides — a background pill, a day + * number — so it animates between its two positions and sizes. + * + * ### Why nothing renders in the overlay + * + * By default a travelling piece is painted into an overlay above the *entire* + * regular tree, so it can fly over anything in its way. That is right for a + * thumbnail crossing a screen, and wrong here: everything is moving inside one + * grid, and the overlay meant every untagged neighbour — the selection outline, + * the "+N" markers — spent the transition buried under pieces that had left the + * tree's z-order behind. Lifting each of them out in turn fixed the burying and + * bought a worse problem: they then floated over the grid on their own layer, + * out of step with it. + * + * Rendering in place puts everything back in one z-order and one clip, which is + * what makes the grid read as a single surface changing shape rather than a + * stack of pieces sliding past each other. Nothing here needs to escape its + * ancestors: the cells, marks and markers all travel within the grid. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { + val morph = LocalMonthMorph.current ?: return this + return with(morph.shared) { + this@morphElement.sharedElement( + sharedContentState = rememberSharedContentState(key), + animatedVisibilityScope = morph.visibility, + renderInOverlayDuringTransition = false, + ) + } +} + +/** + * Tag content that means the same thing but *is drawn differently* on each side — + * a 5dp dot and a titled bar — so only the bounds are shared and the contents + * cross-fade inside them. + * + * [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than + * scaling: a bar's title laid out at the dot's 5dp and then scaled up would + * arrive as a smear. Remeasuring keeps the text at its real size throughout and + * simply clips it while there is no room, so what grows is the pill, not the type. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { + val morph = LocalMonthMorph.current ?: return this + return with(morph.shared) { + this@morphBounds.sharedBounds( + sharedContentState = rememberSharedContentState(key), + animatedVisibilityScope = morph.visibility, + enter = fadeIn(), + exit = fadeOut(), + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + renderInOverlayDuringTransition = false, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 7f501dc..41a4d8f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -1,14 +1,29 @@ package de.jeanlucmakiola.calendula.ui.month +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -19,6 +34,12 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -26,6 +47,7 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,20 +58,29 @@ 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.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.key +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -63,12 +94,17 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader +import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow +import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle 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.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha @@ -80,18 +116,26 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.animateItemMotion +import de.jeanlucmakiola.floret.identity.itemEnter import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus import kotlinx.datetime.YearMonth import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toLocalDateTime +import kotlin.math.abs import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -102,6 +146,7 @@ fun MonthScreen( selectedView: CalendarView, onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, @@ -112,8 +157,11 @@ fun MonthScreen( viewModel: MonthViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val continuousState by viewModel.continuousState.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() + val viewStyle by viewModel.viewStyle.collectAsStateWithLifecycle() + val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming @@ -128,16 +176,92 @@ fun MonthScreen( val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() - val isOnCurrentMonth = when (val s = state) { - is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month) - else -> true + val scrolling = viewStyle.isScrolling + val dense = viewStyle == MonthViewStyle.Dense + + // Today, from whichever state is driving; the clock only covers the first + // frame, before either has loaded. + val today = when { + scrolling -> (continuousState as? ContinuousMonthUiState.Success)?.today + else -> (state as? MonthUiState.Success)?.today + } ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + + // The two scrolling styles index their items differently — Continuous by + // month (which the week start can't move), Dense by week (which it can) — so + // the list state is rebuilt when either changes rather than carrying an + // offset into a coordinate space that no longer means the same thing. + val listState = key(viewStyle, weekStart) { + rememberLazyListState( + initialFirstVisibleItemIndex = if (dense) { + weekIndexOf(LocalDate(month.year, month.month, 1), weekStart) + } else { + itemIndexForMonth(monthIndexOf(month)) + }, + ) } - // Drives whether the title carries the year. Falls back to the clock only - // while the first load is in flight, when there is no state to read today from. - val currentYear = when (val s = state) { - is MonthUiState.Success -> s.today.year - else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year + // Which month the viewport is showing. Continuous reads it off the block + // whose sticky header is up; Dense has no blocks, so it takes the month the + // row below the top edge mostly sits in, which flips as that month's first + // full week comes into view. + val visibleMonth by remember(dense, weekStart) { + derivedStateOf { + if (dense) { + val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart) + .plus(3, DateTimeUnit.DAY) + YearMonth(midweek.year, midweek.month) + } else { + yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex)) + } + } + } + val titleMonth = if (scrolling) visibleMonth else month + + // Feed the visible range back so the sliding data window can follow. Both + // styles report *months*, whatever they index by — one window serves both. + LaunchedEffect(listState, scrolling, dense, weekStart) { + if (!scrolling) return@LaunchedEffect + snapshotFlow { + // Empty before the first layout pass — and reporting a default of 0 + // for it would claim January 1900 is on screen and drag the whole + // window back there. + val visible = listState.layoutInfo.visibleItemsInfo + when { + visible.isEmpty() -> null + dense -> monthIndexForWeek(visible.first().index, weekStart) to + monthIndexForWeek(visible.last().index, weekStart) + else -> monthIndexForItem(visible.first().index) to + monthIndexForItem(visible.last().index) + } + } + .filterNotNull() + .distinctUntilChanged() + .collect { (first, last) -> viewModel.onVisibleMonthsChanged(first, last) } + } + + // Keep the paged month tracking the scrolling grid. The scrolling styles + // navigate by scroll position and never touch the paged month otherwise, so + // without this it stays wherever paged navigation last left it (today's + // month, on a fresh open) — and switching to a paged style, or reseeding the + // other scrolling style's list state, which both read off it, would snap + // back there rather than staying on the month you were looking at. + LaunchedEffect(listState, scrolling, dense, weekStart) { + if (!scrolling) return@LaunchedEffect + snapshotFlow { visibleMonth } + .distinctUntilChanged() + .collect { viewModel.syncScrollMonth(it) } + } + + val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month) + + // Continuous names the month on the block's own sticky header, so the bar + // carries the year instead of repeating it two lines further down. Dense has + // no header to defer to, so it keeps the full title. + val locale = currentLocale() + val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) { + titleMonth.year.toString() + } else { + formatMonthTitle(titleMonth, locale, currentYear = today.year) } // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). @@ -152,19 +276,48 @@ fun MonthScreen( viewModel.goToPrev() } // Slide toward today: viewing the future → today comes in from the left - // (back), viewing the past → from the right (forward). + // (back), viewing the past → from the right (forward). The continuous stream + // scrolls to today's week instead — there is nothing to slide. val jumpToToday = { - slideDir = when (val s = state) { - is MonthUiState.Success -> - if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 - else -> 0 + if (scrolling) { + scope.launch { + listState.animateScrollToItem( + if (dense) weekIndexOf(today, weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(today.year, today.month))), + ) + } + Unit + } else { + slideDir = when (val s = state) { + is MonthUiState.Success -> + if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1 + else -> 0 + } + viewModel.goToToday() } - viewModel.goToToday() } // Drawer jump-to-date: slide from the side the target month lies on. val jumpToDate: (LocalDate) -> Unit = { target -> - slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 - viewModel.goToDate(target) + if (scrolling) { + scope.launch { + listState.animateScrollToItem( + if (dense) weekIndexOf(LocalDate(target.year, target.month, 1), weekStart) + else itemIndexForMonth(monthIndexOf(YearMonth(target.year, target.month))), + ) + } + } else { + slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 + viewModel.goToDate(target) + } + } + // Selecting a day in the split grid can cross into a neighbour month — a + // tapped leading/trailing day follows to its own month. Point the slide the + // way the month is actually moving, so the incoming page travels the right + // direction instead of reusing whatever the last swipe left in slideDir. + val selectDay: (LocalDate) -> Unit = { date -> + val target = YearMonth(date.year, date.month) + if (target != month) slideDir = if (target < month) -1 else 1 + viewModel.selectDate(date) } ModalNavigationDrawer( @@ -174,7 +327,7 @@ fun MonthScreen( drawerContent = { CalendarDrawer( currentView = selectedView, - currentDate = LocalDate(month.year, month.month, 1), + currentDate = LocalDate(titleMonth.year, titleMonth.month, 1), viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) @@ -195,8 +348,7 @@ fun MonthScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { MonthTopBar( - month = month, - currentYear = currentYear, + title = topBarTitle, selectedView = selectedView, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, @@ -213,11 +365,9 @@ fun MonthScreen( onToday = jumpToToday, onCreate = { // Anchor on today when its month is shown, else the 1st. - val today = Clock.System.now() - .toLocalDateTime(TimeZone.currentSystemDefault()).date onCreateEvent( if (isOnCurrentMonth) today - else LocalDate(month.year, month.month, 1), + else LocalDate(titleMonth.year, titleMonth.month, 1), null, ) }, @@ -231,15 +381,40 @@ fun MonthScreen( ) { WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { - MonthContent( - state = state, - slideDir = slideDir, - showWeekNumbers = showWeekNumbers, - onSwipeNext = goNext, - onSwipePrev = goPrev, - onRetry = jumpToToday, - onOpenDay = onOpenDay, - ) + if (scrolling) { + ContinuousMonthContent( + state = continuousState, + listState = listState, + dense = dense, + showWeekNumbers = showWeekNumbers, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } else if (viewStyle == MonthViewStyle.Split) { + SplitMonthContent( + state = state, + selected = selectedDate, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onSelectDay = selectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = { onCreateEvent(it, null) }, + ) + } else { + MonthContent( + state = state, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + onOpenDay = onOpenDay, + ) + } } } } @@ -256,27 +431,10 @@ private fun MonthContent( onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, ) { - val density = LocalDensity.current - val threshold = with(density) { 6.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() - - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, @@ -303,11 +461,49 @@ private fun MonthContent( } } +/** + * Content for both scrolling styles. No swipe detector and no [AnimatedContent]: + * vertical scrolling owns the gesture, and the list never swaps wholesale — it + * keeps scrolling while the window behind it reloads. + */ +@Composable +private fun ContinuousMonthContent( + state: ContinuousMonthUiState, + listState: LazyListState, + dense: Boolean, + showWeekNumbers: Boolean, + onRetry: () -> Unit, + onOpenDay: (LocalDate) -> Unit, +) { + when (state) { + // The scrolling styles get their own skeleton rather than the paged + // grid's: same row height, same header, so nothing reflows when the + // months land on top of it. + ContinuousMonthUiState.Loading -> ContinuousMonthSkeleton(dense = dense) + is ContinuousMonthUiState.Failure -> + CalendarFailure(reason = state.reason, onRetry = onRetry) + is ContinuousMonthUiState.Success -> if (dense) { + DenseMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } else { + ContinuousMonthGrid( + state = state, + listState = listState, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MonthTopBar( - month: YearMonth, - currentYear: Int, + title: String, selectedView: CalendarView, onCycleView: () -> Unit, onOpenDrawer: () -> Unit, @@ -316,11 +512,10 @@ private fun MonthTopBar( onToday: () -> Unit, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, ) { - val locale = currentLocale() TopAppBar( title = { Text( - text = formatMonthTitle(month, locale, currentYear), + text = title, style = MaterialTheme.typography.titleLarge, ) }, @@ -351,7 +546,7 @@ private fun MonthTopBar( } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { +internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() val days = remember(weekStart, locale) { (0 until 7).map { offset -> @@ -388,14 +583,38 @@ private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp -private val CELL_SHAPE = RoundedCornerShape(12.dp) +/** Named separately because the split style's selection outline draws its own + * rounded rect and has to match this radius exactly. */ +private val CELL_CORNER = 12.dp +private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) + +/** Width of the split style's selected-day outline. */ +private val SPLIT_SELECTION_STROKE = 1.5.dp private const val MAX_EVENT_ROWS = 3 +/** + * Row height in the continuous grid. The paged grid divides the viewport between + * however many rows the month has; a scrolling stream has no such bound, so it + * fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows — + * close to what a five-row month gets on a typical phone. + */ +private val CONTINUOUS_ROW_HEIGHT = 112.dp + +/** + * Whitespace below a month block, before the next month's header. Paired with the + * header's own top padding it gives the seam between two months roughly a row's + * worth of air — enough that the blocks read as separate without a rule between + * them. + */ +private val CONTINUOUS_MONTH_GAP = 20.dp + @Composable -private fun MonthGrid( +internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ + selected: LocalDate? = null, ) { Column( modifier = Modifier @@ -406,13 +625,15 @@ private fun MonthGrid( .padding(horizontal = 8.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { + val month = state.month state.weeks.forEach { week -> MonthWeekRow( week = week, today = state.today, - month = state.month, + inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + selected = selected, modifier = Modifier .fillMaxWidth() .weight(1f), @@ -421,6 +642,1032 @@ private fun MonthGrid( } } +/** + * The continuous grid (#38): months stacked into one vertical scroll, each a + * self-contained block under its own sticky header. + * + * A block shows *only its own days* — the boundary week keeps its seven columns + * so nothing shifts sideways, but the neighbour month's cells are left blank + * rather than filled with dimmed duplicates of days shown again a block later. + * The whitespace either side of a header is what separates one month from the + * next; scrolling itself never stops at a page seam. + * + * The list is indexed by *absolute month index* (two items per month: header, + * then block), so a block's identity never changes and the week-start preference + * can't move it. Months the loaded window hasn't reached render as a skeleton of + * the right height and pull the window along behind them. + */ +@Composable +internal fun ContinuousMonthGrid( + state: ContinuousMonthUiState.Success, + listState: LazyListState, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val monthCount = remember { continuousMonthCount() } + val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(bottom = 96.dp), + ) { + repeat(monthCount) { index -> + val month = yearMonthForIndex(index) + stickyHeader(key = "header-$index", contentType = "month-header") { + ContinuousMonthHeader( + month = month, + currentYear = state.today.year, + isCurrent = month == todayMonth, + ) + } + item(key = "month-$index", contentType = "month-block") { + ContinuousMonthBlock( + month = month, + weeks = state.monthsByIndex[index], + weekStart = state.weekStart, + today = state.today, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + ) + } + } + } +} + +/** + * One month's week rows. Sized by [weekRowsInMonth] even before its data has + * loaded, so the skeleton occupies exactly what the real block will and a scroll + * across unloaded months doesn't jump when they arrive. + */ +@Composable +private fun ContinuousMonthBlock( + month: YearMonth, + weeks: List?, + weekStart: DayOfWeek, + today: LocalDate, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, +) { + val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = CONTINUOUS_MONTH_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (weeks == null) { + repeat(rowCount) { ContinuousWeekPlaceholder() } + } else { + weeks.forEach { week -> + MonthWeekRow( + week = week, + today = today, + inMonth = { it.month == month.month && it.year == month.year }, + // The block owns its month alone: a day from either + // neighbour is left out entirely rather than dimmed. + blankOutside = true, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) + } + } + } +} + +/** + * The month label that pins above its block. Opaque `surface` so the rows scroll + * under it cleanly, and generous vertical padding — the whitespace around the + * label is what makes each month read as its own section. The rule beneath it + * closes the header off against the grid; it is the one part of this layout the + * user is still deciding on. + */ +@Composable +private fun ContinuousMonthHeader(month: YearMonth, currentYear: Int, isCurrent: Boolean) { + val locale = currentLocale() + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(top = 20.dp), + ) { + Text( + text = formatMonthTitle(month, locale, currentYear), + style = MaterialTheme.typography.titleMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 0.dp), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), + ) + } +} + +/** + * The Dense style (#38): one uninterrupted vertical stream of weeks, months + * flowing into each other. + * + * Kept alongside the block layout rather than replaced by it — the seams that + * make months legible are exactly what someone wanting a dense calendar doesn't + * want. There is no month boundary to dim across here, so every day reads as + * in-month and the 1st names its month: the only marker of where one ends. + * + * Indexed by absolute *week* number, so a row's identity never changes and there + * is no page seam to scroll across. Weeks the loaded window hasn't reached yet + * render as a skeleton and pull the window along behind them. + */ +@Composable +internal fun DenseMonthGrid( + state: ContinuousMonthUiState.Success, + listState: LazyListState, + showWeekNumbers: Boolean, + onOpenDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } + LazyColumn( + state = listState, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + ) { + items(count = weekCount, key = { it }) { index -> + val week = state.weeksByIndex[index] + if (week == null) { + ContinuousWeekPlaceholder() + } else { + MonthWeekRow( + week = week, + today = state.today, + // Every day in the stream belongs to a month equally — there + // is no "other month" to recede here. + inMonth = { true }, + showWeekNumbers = showWeekNumbers, + onOpenDay = onOpenDay, + labelMonthOnFirst = true, + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) + } + } + } +} + +/** Which way a drag went, decided once per gesture and then held. */ +private enum class DragAxis { Undecided, Horizontal, Vertical } + +/** + * The month grid's drag gesture: horizontal pages the month, vertical expands or + * collapses the split style (#53). The grid doesn't follow the finger, so there is + * no distance to rubber-band against — it commits the moment the accumulated drag + * clears the threshold, once per gesture, exactly as + * [rememberCalendarPageSwipe][de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe] + * does for the week and day views. + * + * This is the month's own detector rather than that shared one because it needs + * two axes, and the axis is **locked on the first movement and held for the whole + * gesture** so a drag can page or expand but never both. Two independent + * detectors on one surface would each see their own component of a diagonal drag + * and both fire. + * + * The horizontal threshold is the shared one. The vertical is larger — swapping + * the whole layout out deserves a more deliberate pull than stepping to the next + * month. + * + * [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical + * axis unclaimed: the lock still happens, so a vertical drag there does nothing + * rather than being re-read as a page turn. + */ +@Composable +private fun rememberMonthSwipeModifier( + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onExpand: (() -> Unit)? = null, + onCollapse: (() -> Unit)? = null, +): Modifier { + val density = LocalDensity.current + val pageThreshold = with(density) { CALENDAR_SWIPE_THRESHOLD.toPx() } + val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() } + return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) { + var accum = Offset.Zero + var axis = DragAxis.Undecided + var fired = false + detectDragGestures( + onDragStart = { + accum = Offset.Zero + axis = DragAxis.Undecided + fired = false + }, + onDragEnd = { + accum = Offset.Zero + axis = DragAxis.Undecided + fired = false + }, + onDragCancel = { + accum = Offset.Zero + axis = DragAxis.Undecided + fired = false + }, + onDrag = { _, drag -> + accum += drag + if (axis == DragAxis.Undecided) { + // Ties go horizontal, keeping paging the default reading of an + // ambiguous drag as it was before the vertical axis existed. + axis = if (abs(accum.x) >= abs(accum.y)) { + DragAxis.Horizontal + } else { + DragAxis.Vertical + } + } + // Both axes commit the instant the drag clears their threshold, + // rather than on release — see [rememberCalendarPageSwipe], which + // does the same for the week and day views. + if (!fired) { + val commit = when (axis) { + DragAxis.Horizontal -> when { + accum.x < -pageThreshold -> onSwipeNext + accum.x > pageThreshold -> onSwipePrev + else -> null + } + DragAxis.Vertical -> when { + accum.y > expandThreshold -> onExpand + accum.y < -expandThreshold -> onCollapse + else -> null + } + DragAxis.Undecided -> null + } + if (commit != null) { + fired = true + commit() + } + } + }, + ) + } +} + +/** Drag distance that commits an expand/collapse — deliberately longer than a page. */ +private val MONTH_EXPAND_THRESHOLD = 48.dp + +/** + * Split style content: the compact grid keeps the month swipe, the pane below it + * lists whatever day is selected — and a downward drag trades the pane away for + * the full paged grid, an upward one brings it back (#53). + * + * The grid slides between months like the paged style, which it can only do + * because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it + * stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on + * top of swapping the grid — the pane now holds still and only the grid moves. + * + * Expansion is deliberately **not** a stored preference. It is a way to look at + * the month you are on, not a fourth style; persisted, someone would expand it + * once and later find their Split style permanently changed with nothing on + * screen to explain why. [rememberSaveable] carries it across a rotation, which + * is as long as it should live. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun SplitMonthContent( + state: MonthUiState, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onRetry: () -> Unit, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, +) { + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + var expanded by rememberSaveable { mutableStateOf(false) } + // Back collapses before it does anything else. Expanding replaces the whole + // screen, so it is a place you can be, and every other place in the app can + // be backed out of. Declared deeper than CalendarHost's view-stack handler, + // which is what makes it win while it is enabled. + BackHandler(enabled = expanded) { expanded = false } + // The swipe wraps the grid rather than living inside it: mid-transition + // there are two grids, and the gesture belongs to neither. The pane is left + // out of it — it scrolls and is full of tappable rows. + val swipeModifier = rememberMonthSwipeModifier( + onSwipeNext = onSwipeNext, + onSwipePrev = onSwipePrev, + onExpand = { expanded = true }, + onCollapse = { expanded = false }, + ) + + when (state) { + MonthUiState.Loading -> MonthGridLoading() + is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry) + is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) { + AnimatedContent( + targetState = expanded, + modifier = Modifier.fillMaxSize(), + // Both branches fill the same box — the grid grows into exactly the + // room the pane gives up — so there is no size change to contain. + // The travel between them is the shared elements' job, not a slide. + transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + label = "split-expand-transition", + ) { isExpanded -> + CompositionLocalProvider( + // Null under reduced motion: nothing is tagged, nothing + // travels, and the cross-fade above is the whole transition. + LocalMonthMorph provides if (reduceMotion) { + null + } else { + MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent) + }, + ) { + SplitMonthBody( + expanded = isExpanded, + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onSetExpanded = { expanded = it }, + ) + } + } + } + } +} + +/** The two faces of the split style, sharing one set of morph tags. */ +@Composable +private fun SplitMonthBody( + expanded: Boolean, + state: MonthUiState.Success, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + onSetExpanded: (Boolean) -> Unit, +) { + if (expanded) { + SplitMonthExpanded( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + // A tap in the expanded grid picks the day and drops back, which + // gives the expanded month a job — a chooser you dip into — rather + // than a mode you can get stranded in. The collapse then runs with + // the selection already set, so the pane arrives showing the day + // you picked. + onPickDay = { + onSelectDay(it) + onSetExpanded(false) + }, + onCollapse = { onSetExpanded(false) }, + ) + } else { + SplitMonthCollapsed( + state = state, + selected = selected, + slideDir = slideDir, + showWeekNumbers = showWeekNumbers, + swipeModifier = swipeModifier, + onSelectDay = onSelectDay, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + onExpand = { onSetExpanded(true) }, + ) + } +} + +/** The split style at rest: compact grid, handle, then the selected day's events. */ +@Composable +private fun SplitMonthCollapsed( + state: MonthUiState.Success, + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onSelectDay: (LocalDate) -> Unit, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + onExpand: () -> Unit, +) { + val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + + Column(Modifier.fillMaxSize()) { + // Grid and handle drag as one surface — the handle is what advertises the + // gesture, so it has to answer to it as well as to a tap. The pane is + // outside: it scrolls, and is full of tappable rows. + Column(swipeModifier) { + AnimatedContent( + // The selection travels *with* the state so each page keeps its + // own. Read from outside, both pages would show the incoming + // one, and paging visibly threw the marker across the outgoing + // grid — onto the new month's 1st, which the old grid still + // shows among its trailing days — before the new page arrived. + targetState = state to selected, + // Keyed on the month alone, so a provider notification refreshing + // the month you are on — or a tap moving the selection within it + // — updates in place instead of sliding. + contentKey = { (s, _) -> s.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-month-transition", + ) { (s, sel) -> + SplitMonthGrid( + state = s, + selected = sel, + showWeekNumbers = showWeekNumbers, + onSelectDay = onSelectDay, + ) + } + SplitExpandHandle(expanded = false, onToggle = onExpand) + } + SplitDayPane( + date = selected, + today = state.today, + // Null, not empty: the selection moves to the new month before + // its data arrives, and a missing key means "not loaded yet". + // Passing an empty list would claim the day was free. + events = state.instancesByDay[selected], + zone = state.zone, + onOpenDay = onOpenDay, + onEventClick = onEventClick, + onCreateEvent = onCreateEvent, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } +} + +/** + * The split style pulled open: the pane is gone and the month gets the whole + * screen in the paged style's own vocabulary — real event bars and pills instead + * of dots. The handle stays, now at the foot of the grid, to pull it back. + * + * No selection marker here, deliberately. Once the cells carry real event bars + * the outline is one mark too many, and it has nothing to say: every day is a tap + * away from being the selected one, and tapping is what closes this view. + */ +@Composable +private fun SplitMonthExpanded( + state: MonthUiState.Success, + /** Marked nowhere here; it only anchors the outline's morph. */ + selected: LocalDate, + slideDir: Int, + showWeekNumbers: Boolean, + swipeModifier: Modifier, + onPickDay: (LocalDate) -> Unit, + onCollapse: () -> Unit, +) { + val slideSpec = rememberCalendarSlideSpec() + val fadeSpec = rememberCalendarFadeSpec() + val reduceMotion = rememberReduceMotion() + + // The whole screen drags here, handle included — there is no pane to keep out + // of it, and the handle is the obvious thing to reach for on the way back. + Column(Modifier.fillMaxSize().then(swipeModifier)) { + AnimatedContent( + targetState = state to selected, + modifier = Modifier.weight(1f), + contentKey = { (s, _) -> s.month }, + transitionSpec = { + calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) + }, + label = "split-expanded-month-transition", + ) { (s, sel) -> + MonthGrid( + state = s, + showWeekNumbers = showWeekNumbers, + onOpenDay = onPickDay, + selected = sel, + ) + } + SplitExpandHandle(expanded = true, onToggle = onCollapse) + } +} + +/** + * The grab handle at the seam between grid and pane — the same M3 drag-handle + * pill a bottom sheet uses, for the same reason: it advertises that the surface + * moves. + * + * The drag itself lives on the grid, not here. This exists so the gesture is + * findable at all, and it takes taps too — a hidden swipe is no use to someone + * who never tries it, or who can't make the gesture. + */ +@Composable +private fun SplitExpandHandle( + expanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = stringResource( + if (expanded) R.string.month_split_collapse else R.string.month_split_expand, + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(SPLIT_HANDLE_ROW_HEIGHT) + .clickable(onClick = onToggle) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT) + // Tagged too, so it slides from the pane's seam down to the foot + // of the grid rather than blinking out at one and in at the other. + .morphElement(MonthMorphKey.Handle) + .background(MaterialTheme.colorScheme.outlineVariant, CircleShape), + ) + } +} + +// --- Split style (#53) ---------------------------------------------------- + +/** + * Row height in the split grid. Only a day number and a row of dots to seat, so + * it's roughly a third of a paged row — which is the point: the space it gives + * up goes to the day pane below. + */ +private val SPLIT_ROW_HEIGHT = 46.dp +private val SPLIT_DOT_SIZE = 5.dp +// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for +// the paged grid's lanes, so the two caps have to be the same number or a dot +// would have no bar to become (#53). + +/** + * Rows the split grid always reserves — the most any month needs. A month that + * fits in fewer pads the remainder with blank rows rather than shrinking, which + * is what lets the pane below hold still from month to month. + */ +private const val SPLIT_GRID_ROWS = 6 + +/** + * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a + * comfortable tap target on its own. + */ +private val SPLIT_HANDLE_WIDTH = 32.dp +private val SPLIT_HANDLE_HEIGHT = 4.dp +private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp + +/** + * The split style's grid (#53): the month compressed to day numbers and event + * dots, with the selected day listed underneath by [SplitDayPane]. + * + * Tapping selects rather than drilling into the Day view — the pane is the + * answer to "what's on this day", so opening a whole screen for it would defeat + * the layout. The full Day view stays one tap away on the pane's date header. + */ +@Composable +internal fun SplitMonthGrid( + state: MonthUiState.Success, + selected: LocalDate, + showWeekNumbers: Boolean, + onSelectDay: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val month = state.month + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.weeks.forEach { week -> + Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) { + if (showWeekNumbers) { + WeekNumberGutter( + weekStart = week.days.first(), + modifier = Modifier + .width(WEEK_NUMBER_GUTTER) + .fillMaxHeight(), + ) + } + week.days.forEachIndexed { col, day -> + val inMonth = day.month == month.month && day.year == month.year + // Seated by lane rather than gathered by colour, so each dot + // is the event the expanded grid draws in that same lane. + val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) + SplitDayCell( + date = day, + events = seated, + hidden = (week.countByDay[day] ?: 0) - seated.size, + isToday = day == state.today, + // A page marks only the days its own month owns. Paging + // moves the selection before this month's replacement + // has loaded, so for a moment the grid is asked to mark + // the *next* month's 1st — a date it shows among its + // trailing days, where the marker would fade in just + // before the page slid away. Selection always lands + // inside the month it belongs to, so scoping it here + // removes that without timing anything against the + // page transition. + isSelected = day == selected && inMonth, + inMonth = inMonth, + dark = dark, + onClick = { onSelectDay(day) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + } + // Hold the grid at a constant height whatever shape the month is, so the + // pane beneath it doesn't move as you page and one month can slide over + // another without a height change under it. + repeat(SPLIT_GRID_ROWS - state.weeks.size) { + Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) + } + } +} + +/** + * One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. + * + * Selection and today are deliberately different signals — a tinted, outlined + * cell versus the filled circle the other views already use for today — so the + * two read at once when they land on the same day. + */ +@Composable +private fun SplitDayCell( + date: LocalDate, + events: List, + /** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */ + hidden: Int, + isToday: Boolean, + isSelected: Boolean, + inMonth: Boolean, + dark: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // Selection is the outline's job alone. Tinting the fill as well lightened + // the whole cell, which read as a second state on top of the day rather than + // as a mark on it — and fought today's own circle when they coincided. + val background = when { + inMonth -> MaterialTheme.colorScheme.surfaceContainer + else -> MaterialTheme.colorScheme.surfaceContainerLow + } + // Each cell fades its own outline, so moving the selection reads as one + // mark crossing the grid rather than as an outline blinking out here and + // reappearing there. Snapped under reduced motion. + // + // Held as a State and read *inside the draw lambda below*, never unwrapped + // here. Read in composition it made every frame of the fade recompose all + // 42 cells at once, which is what made the outline shiver rather than fade. + // Kept in the draw phase, the animation touches nothing but the pixels. + val reduceMotion = rememberReduceMotion() + // The outline runs on the *fast* effects spec, unlike everything else here. + // It is a small mark that only ever appears or disappears, and at the shared + // pace it lingered after the thing it marks had already moved. + val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec() + val selection = animateFloatAsState( + targetValue = if (isSelected) 1f else 0f, + animationSpec = if (reduceMotion) snap() else outlineSpec, + label = "split-day-selection", + ) + val outlineColor = MaterialTheme.colorScheme.primary + val density = LocalDensity.current + val outlineStroke = with(density) { SPLIT_SELECTION_STROKE.toPx() } + val outlineRadius = with(density) { CELL_CORNER.toPx() } + // Background and content are separate layers, mirroring how the paged row is + // built — and so the pill can be tagged for the morph on its own. Tagged as + // one piece with its contents inside, the dots would be nested shared + // elements within a shared element and travel twice. + Box( + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .selectable(selected = isSelected, onClick = onClick), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .fillMaxSize() + .morphElement(MonthMorphKey.Cell(date)) + .clip(CELL_SHAPE) + .background(background), + ) + // The outline is a layer of its own, deliberately *untagged*. Drawn on the + // morphing pill it rode the cell's shared bounds, so collapsing painted it + // at the expanded cell's size and shrank it down — a full-height outline + // flashing over a grid that no longer had full-height cells. Untagged it + // is laid out where the collapsed cell actually is and simply fades in, + // which is the only size it is ever true at. + // + // Tagged only while it is the selected day, and paired with an invisible + // stand-in over the same cell in the expanded grid, so it travels with its + // cell instead of fading in place. Tagged unconditionally it would put a + // key on all 42 cells against the one partner the expanded grid offers, + // and the other 41 would fly in from the layout origin. + Box( + Modifier + .fillMaxSize() + .then( + if (isSelected) { + Modifier.morphBounds(MonthMorphKey.Selection(date)) + } else { + Modifier + }, + ) + .drawBehind { + val alpha = selection.value + if (alpha <= 0f) return@drawBehind + // Inset by half the stroke: a stroke straddles the path it + // follows, so drawn on the bounds themselves the outer half + // would fall outside the cell and be clipped to a hairline + // along the edges. + val inset = outlineStroke / 2f + drawRoundRect( + color = outlineColor.copy(alpha = alpha), + topLeft = Offset(inset, inset), + size = Size(size.width - outlineStroke, size.height - outlineStroke), + cornerRadius = CornerRadius(outlineRadius - inset), + style = Stroke(width = outlineStroke), + ) + }, + ) + Column( + modifier = Modifier.fillMaxSize().padding(top = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DayNumberCell( + date = date, + isToday = isToday, + inMonth = inMonth, + modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)), + ) + Spacer(Modifier.height(2.dp)) + SplitDots(date = date, events = events, hidden = hidden, dark = dark) + } + } +} + +/** + * One dot per seated event, in lane order, plus a "+N" for those that didn't fit. + * + * Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the + * dots line up with the bars the expanded grid draws, and collapsing two events + * that share a calendar into one dot would both undercount the day and leave a + * bar with no dot to grow out of. + */ +@Composable +private fun SplitDots(date: LocalDate, events: List, hidden: Int, dark: Boolean) { + if (events.isEmpty()) return + val soften = LocalSoftenColors.current + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + events.forEach { event -> + // The dot keeps its own circle and the bar its 4dp corners; they + // cross-fade inside shared bounds, and at the dot's size the two + // radii are a fraction of a pixel apart. + // + // morphBounds goes *outside* the size: the shared bounds have to be + // free to drive the measurement. Behind a fixed .size() the dot stayed + // 5dp for the whole transition however far its bar had travelled, and + // only snapped to full width once the transition ended — the growth + // never happened, it was clamped away. + // + // *Every* dot is tagged, including those in the middle of a multi-day + // run: the expanded grid gives each covered column its own anchor + // inside the bar, so each one has a real place to come from. + Box( + modifier = Modifier + .morphBounds(MonthMorphKey.Event(date, event.instanceId)) + .size(SPLIT_DOT_SIZE) + .background(eventFill(event.color, dark, soften), CircleShape), + ) + } + if (hidden > 0) { + // Tagged, not lifted: this count and the expanded grid's dot row are + // the same marker on the same day, so it travels with its cell like + // everything else rather than riding above the grid on its own layer. + Text( + text = "+$hidden", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)), + ) + } + } +} + +/** + * The selected day's events, in the agenda's own row vocabulary so the two + * surfaces read as one app. The date header opens the full Day view, matching + * the Week and Agenda headers (#37). + */ +@Composable +internal fun SplitDayPane( + date: LocalDate, + today: LocalDate, + /** Null while the selected day's month is still loading; empty means free. */ + events: List?, + zone: TimeZone, + onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, + onCreateEvent: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + val dimCutoff = LocalDimCutoff.current + // A day's list used to be replaced outright, so changing day popped one set + // of rows out and another in. The incoming list rises as it fades, which is + // the same entrance the rest of the family uses for resolved content. + val enter = itemEnter() + val fadeSpec = rememberCalendarFadeSpec() + Column(modifier = modifier) { + AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay) + AnimatedContent( + targetState = date to events, + modifier = Modifier.weight(1f).fillMaxWidth(), + // Whether the day is still loading is part of the identity, so the + // skeleton hands over to the real list with the same transition. + contentKey = { (d, e) -> d to (e == null) }, + transitionSpec = { enter togetherWith fadeOut(fadeSpec) }, + label = "split-day-pane", + ) { (day, dayEvents) -> + when { + // The day isn't in the loaded grid yet — paging lands the + // selection on the new month before its events arrive. "Nothing + // scheduled" here would be a claim about the day rather than + // about the wait. + dayEvents == null -> SplitPaneSkeleton() + dayEvents.isEmpty() -> AgendaEmptyDayRow( + text = stringResource(R.string.month_split_no_events), + onClick = { onCreateEvent(day) }, + ) + else -> LazyColumn( + // Bottom inset clears the FAB stack so the last row stays + // tappable. + contentPadding = PaddingValues(bottom = 96.dp), + ) { + itemsIndexed( + items = dayEvents, + key = { _, event -> event.instanceId }, + ) { index, event -> + AgendaEventRow( + event = event, + day = day, + zone = zone, + position = positionOf(index, dayEvents.size), + dimmed = dimCutoff != null && event.hasEnded(dimCutoff), + onClick = { onEventClick(event) }, + // An event added to or removed from the day you are + // already looking at moves its neighbours rather + // than teleporting them. + modifier = animateItemMotion(), + ) + } + } + } + } + } +} + +/** Stand-in rows for a day pane whose month hasn't arrived yet. */ +@Composable +private fun SplitPaneSkeleton() { + val pulse = rememberSkeletonPulse() + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(2) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(SPLIT_SKELETON_ROW_HEIGHT) + .alpha(pulse) + .background( + MaterialTheme.colorScheme.surfaceContainer, + MaterialTheme.shapes.medium, + ), + ) + } + } +} + +private val SPLIT_SKELETON_ROW_HEIGHT = 56.dp + +/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */ +@Composable +private fun ContinuousWeekPlaceholder() { + val pulse = rememberSkeletonPulse() + Row( + modifier = Modifier + .fillMaxWidth() + .height(CONTINUOUS_ROW_HEIGHT), + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .alpha(pulse) + .background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE), + ) + } + } +} + +/** + * The first-frame skeleton for the scrolling styles: the layout they are about + * to become, at the same measurements, so the arriving months replace it in + * place instead of shifting everything. + * + * In practice the window is small enough that this is rarely on screen for long + * — it is there for the calendar big enough to make even one month's worth of + * recurrence expansion take a moment. + */ +@Composable +private fun ContinuousMonthSkeleton(dense: Boolean) { + val pulse = rememberSkeletonPulse() + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .clipToBounds(), + ) { + if (!dense) { + // Stand-in for the sticky month header, so the first rows start + // where they will once the real one is there. + Box( + modifier = Modifier + .padding(start = 4.dp, top = 20.dp, bottom = 8.dp) + .width(SKELETON_HEADER_WIDTH) + .height(SKELETON_HEADER_HEIGHT) + .alpha(pulse) + .background( + MaterialTheme.colorScheme.surfaceContainerHigh, + MaterialTheme.shapes.small, + ), + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } else { + Spacer(Modifier.height(4.dp)) + } + // More rows than a viewport holds; the clip takes the overflow. + repeat(6) { + ContinuousWeekPlaceholder() + Spacer(Modifier.height(2.dp)) + } + } +} + +private val SKELETON_HEADER_WIDTH = 128.dp +private val SKELETON_HEADER_HEIGHT = 20.dp + +/** + * The slow breath that tells a skeleton from an empty grid. Held at full opacity + * when the system asks for reduced motion — the placeholders still read as + * unfilled without it. + */ +@Composable +private fun rememberSkeletonPulse(): Float { + if (rememberReduceMotion()) return 1f + val transition = rememberInfiniteTransition(label = "skeleton") + val alpha by transition.animateFloat( + initialValue = 1f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900), + repeatMode = RepeatMode.Reverse, + ), + label = "skeleton-alpha", + ) + return alpha +} + /** * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * a multi-day event is one connected bar across the columns; single-day timed @@ -432,14 +1679,24 @@ private fun MonthGrid( private fun MonthWeekRow( week: MonthWeek, today: LocalDate, - month: YearMonth, + inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, + blankOutside: Boolean = false, + labelMonthOnFirst: Boolean = false, + /** + * The selected day, when there is one. Draws *nothing* — it only places the + * invisible counterpart the compact grid's outline morphs against. Null for + * every style but the split style's expanded form, which composes no extra + * layer at all. + */ + selected: LocalDate? = null, ) { val dark = isSystemInDarkTheme() val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) + val morphing = morphInFlight() Row(modifier) { // Optional calendar-week gutter, sized so the seven day columns below @@ -466,15 +1723,20 @@ private fun MonthWeekRow( // as one continuous event. Row(Modifier.matchParentSize()) { week.days.forEach { d -> - val inMonth = d.month == month.month && d.year == month.year Box( Modifier .weight(1f) .fillMaxHeight() .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.Cell(d)) .background( - color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer - else MaterialTheme.colorScheme.surfaceContainerLow, + color = when { + inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer + // A blanked cell draws nothing at all: it is + // the gap that shows where the month starts. + blankOutside -> Color.Transparent + else -> MaterialTheme.colorScheme.surfaceContainerLow + }, shape = CELL_SHAPE, ), ) @@ -484,22 +1746,39 @@ private fun MonthWeekRow( Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { Row(Modifier.fillMaxWidth()) { week.days.forEach { d -> - DayNumberCell( - date = d, - isToday = d == today, - inMonth = d.month == month.month && d.year == month.year, - modifier = Modifier.weight(1f), - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT)) + } else { + DayNumberCell( + date = d, + isToday = d == today, + inMonth = inMonth(d), + // Dense has no month boundaries to dim across and + // no header to name the month, so the 1st does it. + monthLabel = if (labelMonthOnFirst && d.day == 1) { + shortMonthName(d) + } else { + null + }, + modifier = Modifier + .weight(1f) + .morphElement(MonthMorphKey.DayNumber(d)), + ) + } } } // Breathing room between the day number (and today's circle) and the // first event row. Spacer(Modifier.height(DAY_NUMBER_GAP)) + // Clipped at rest so a bar can't spill into the row below, but not + // while a morph is in flight: a mark travels between two different + // rows, and clipping it to the one it is arriving at hides it for + // the whole first half of the journey. See [morphInFlight]. Box( modifier = Modifier .fillMaxWidth() .weight(1f) - .clipToBounds(), + .then(if (morphing) Modifier else Modifier.clipToBounds()), ) { // Spanning bars on their shared lanes. week.spans.filter { it.lane < shownLanes }.forEach { span -> @@ -514,10 +1793,51 @@ private fun MonthWeekRow( x = colW * span.startCol, y = EVENT_ROW_HEIGHT * span.lane, ) + // Anchored on the day the bar starts *in this row*, + // which is where its dot sat. A bar carried in from + // the previous week starts at column 0, and column + // 0's dot is the one that grows into it. + // + // Outside the width/height, so the shared bounds + // drive the measurement instead of being pinned to + // the bar's final size from the first frame. The + // offset stays outside it: that is where the bar + // sits, not how big it is. + .morphBounds( + MonthMorphKey.Event( + date = week.days[span.startCol], + instanceId = span.event.instanceId, + ), + ) .width(colW * cols) .height(EVENT_ROW_HEIGHT) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) + // One invisible slice of the bar per further column it + // covers. A multi-day event has a dot on every day but + // only one bar, so those other dots had nothing to travel + // to and sat still while the rest of the grid moved. + // + // These give each of them a real place inside the bar to + // come out of and go back into, at its own column — so a + // dot drops out of the bar above it rather than appearing + // from nowhere, or from the top of the grid, which is what + // an untagged dot and a partnerless tag respectively did. + for (c in (span.startCol + 1)..span.endCol) { + Box( + Modifier + .offset(x = colW * c, y = EVENT_ROW_HEIGHT * span.lane) + .morphBounds( + MonthMorphKey.Event( + date = week.days[c], + instanceId = span.event.instanceId, + ), + ) + .width(colW) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } } // Single-day timed pills + overflow, per column. Pills fill the // lane slots no bar occupies on THIS day (top-most first), so a @@ -542,6 +1862,7 @@ private fun MonthWeekRow( x = colW * col, y = EVENT_ROW_HEIGHT * freeSlots[i], ) + .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .width(colW) .height(EVENT_ROW_HEIGHT) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), @@ -561,6 +1882,7 @@ private fun MonthWeekRow( dark = dark, modifier = Modifier .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + .morphBounds(MonthMorphKey.Overflow(d)) .width(colW) .padding(horizontal = 3.dp), ) @@ -569,18 +1891,46 @@ private fun MonthWeekRow( } } + // Invisible stand-in for the compact grid's selection outline, so it + // has somewhere to travel to and from. Its own layer rather than a + // child of the cell pill: that pill is itself a tagged piece, and a + // tag nested inside a tag travels twice. Padded to match the outline's + // own bounds over there, or it would arrive at the wrong size. + if (selected != null) { + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + if (d == selected && inMonth(d)) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphBounds(MonthMorphKey.Selection(d)), + ) + } else { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } + } + } + } + // Tap layer: in month view a tap on any day opens that day. Padded and - // clipped to the background pill so the ripple matches it. + // clipped to the background pill so the ripple matches it. A blanked + // cell isn't part of this month, so it takes no taps either. Row(Modifier.matchParentSize()) { week.days.forEach { d -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, - ) + if (blankOutside && !inMonth(d)) { + Spacer(Modifier.weight(1f).fillMaxHeight()) + } else { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) + } } } } @@ -620,6 +1970,7 @@ private fun DayNumberCell( isToday: Boolean, inMonth: Boolean, modifier: Modifier = Modifier, + monthLabel: String? = null, ) { Box( modifier = modifier.height(DAY_NUMBER_HEIGHT), @@ -639,6 +1990,16 @@ private fun DayNumberCell( color = MaterialTheme.colorScheme.onPrimary, ) } + } else if (monthLabel != null) { + Text( + text = "$monthLabel ${date.day}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Visible, + softWrap = false, + ) } else { Text( text = date.day.toString(), @@ -650,6 +2011,16 @@ private fun DayNumberCell( } } +/** Locale-short month name ("Jul", "Juli"), for the 1st in the Dense grid. */ +@Composable +private fun shortMonthName(date: LocalDate): String { + val locale = currentLocale() + return remember(date.month, locale) { + java.time.Month.of(date.month.ordinal + 1) + .getDisplayName(JavaTextStyle.SHORT, locale) + } +} + /** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ @Composable private fun MonthBar( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt new file mode 100644 index 0000000..e5a47a1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -0,0 +1,284 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.math.roundToInt + +/** + * A live, scaled-down Month view in a given [MonthViewStyle], for the settings + * chooser. + * + * It renders the *real* grid composables rather than a drawing of them, so the + * preview cannot drift from the thing it depicts: change a cell's shape or an + * event bar's colour and every preview follows automatically. The trick is + * [requiredSize] — it ignores the incoming constraints, so the grid lays itself + * out at a full phone width and a plausible viewport height, and a + * [graphicsLayer] scale shrinks the finished layout into the card. + * + * The month, today's position and the week start are all real; only the events + * are stand-ins, since the settings screen has no business querying the + * provider for a thumbnail. + */ +@Composable +internal fun MonthStylePreview( + style: MonthViewStyle, + weekStart: DayOfWeek, + height: Dp, + modifier: Modifier = Modifier, +) { + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val scale = height.value / VIRTUAL_HEIGHT.value + val zone = remember { TimeZone.currentSystemDefault() } + val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } + val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) } + + Box( + modifier = modifier + .clipToBounds() + .background(MaterialTheme.colorScheme.surface) + // A preview is a picture, not a control: swallow touches before the + // grid's own clickables see them, and give screen readers one label + // instead of six weeks of day cells. + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes + .forEach { it.consume() } + } + } + } + .clearAndSetSemantics { } + // Measure the grid at a full phone viewport but report the scaled + // size, so the node occupies exactly what it draws. + // + // Modifier.requiredSize would be the obvious way to force the larger + // measurement, but it *centres* content that overflows the incoming + // constraints — which pushed the grid to a negative offset and left + // only its bottom-right corner inside the clip. + .layout { measurable, _ -> + val fullWidth = screenWidth.roundToPx() + val fullHeight = VIRTUAL_HEIGHT.roundToPx() + val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight)) + layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) { + placeable.place(0, 0) + } + } + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0f) + }, + ) { + Column(Modifier.fillMaxSize()) { + WeekdayHeader(weekStart = weekStart, showWeekNumbers = false) + when (style) { + MonthViewStyle.Paged -> MonthGrid( + state = sample.month, + showWeekNumbers = false, + onOpenDay = {}, + ) + MonthViewStyle.Continuous -> ContinuousMonthGrid( + state = sample.continuous, + listState = rememberLazyListState( + initialFirstVisibleItemIndex = itemIndexForMonth( + monthIndexOf(YearMonth(today.year, today.month)), + ), + ), + showWeekNumbers = false, + onOpenDay = {}, + ) + MonthViewStyle.Dense -> DenseMonthGrid( + state = sample.continuous, + listState = rememberLazyListState( + // Start a week above today's, so the preview shows a + // stream running past the viewport rather than one + // beginning at its top edge. + initialFirstVisibleItemIndex = + weekIndexOf(today, weekStart) - 1, + ), + showWeekNumbers = false, + onOpenDay = {}, + ) + MonthViewStyle.Split -> { + SplitMonthGrid( + state = sample.month, + selected = today, + showWeekNumbers = false, + onSelectDay = {}, + ) + SplitDayPane( + date = today, + today = today, + events = sample.month.instancesByDay[today].orEmpty(), + zone = zone, + onOpenDay = {}, + onEventClick = {}, + onCreateEvent = {}, + modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT), + ) + } + } + } + } +} + +/** + * The viewport the preview pretends to be: a phone's width by the height a + * calendar view gets under the top bar. Scaling from a fixed height keeps the + * three styles comparable — each shows the same slice of screen. + */ +private val VIRTUAL_HEIGHT = 440.dp +private val SPLIT_PANE_HEIGHT = 170.dp + +private class SampleMonth( + val month: MonthUiState.Success, + val continuous: ContinuousMonthUiState.Success, +) + +/** + * A month's worth of stand-in events, laid out through the same + * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview + * exercises the real span, lane and overflow logic rather than approximating it. + * + * Colours are raw ARGB on purpose: that is what the provider hands out for an + * event, so a token here would misrepresent what the grid actually renders. + */ +private fun sampleMonthState( + today: LocalDate, + weekStart: DayOfWeek, + zone: TimeZone, +): SampleMonth { + val ym = YearMonth(today.year, today.month) + val first = LocalDate(ym.year, ym.month, 1) + val events = sampleEvents(first, today, zone) + + val weeks = layoutMonthWeeks(ym, weekStart, events, zone) + val month = MonthUiState.Success( + month = ym, + today = today, + weeks = weeks, + instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone), + zone = zone, + ) + + // The month either side of this one: enough for Continuous to show a block, + // the whitespace under it and the next month's header coming up, and for + // Dense to have somewhere to scroll in both directions. + val centre = monthIndexOf(ym) + val window = (centre - 1)..(centre + 1) + val continuous = ContinuousMonthUiState.Success( + today = today, + monthsByIndex = window.associateWith { index -> + val m = yearMonthForIndex(index) + layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) } + }, + weeksByIndex = weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, events, zone) + }, + weekStart = weekStart, + ) + return SampleMonth(month, continuous) +} + +private val SAMPLE_COLORS = listOf( + 0xFF3F7BD4.toInt(), + 0xFFCE5B4C.toInt(), + 0xFF4E9A6A.toInt(), + 0xFF8A63C7.toInt(), +) + +private fun sampleEvents( + firstOfMonth: LocalDate, + today: LocalDate, + zone: TimeZone, +): List { + var id = 0L + fun next() = ++id + + fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance( + instanceId = next(), + eventId = id, + calendarId = 1L, + title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size], + start = day.atTime(hour, 0).toInstant(zone), + end = day.atTime(hour + length, 0).toInstant(zone), + isAllDay = false, + color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size], + location = null, + ) + + fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance( + instanceId = next(), + eventId = id, + calendarId = 1L, + title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size], + // All-day events sit at UTC midnights with an exclusive end. + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size], + location = null, + ) + + // Spread across the month so most weeks carry something, with one multi-day + // bar to show a span bridging cells and a busy today for the split pane. + return buildList { + add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2)) + add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0)) + add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1)) + add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3)) + add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0)) + add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2)) + add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1)) + add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3)) + // Today, so the split pane's list and the grid's dots both have content. + add(timed(today, 9, 1, 0)) + add(timed(today, 12, 1, 1)) + add(timed(today, 15, 2, 3)) + } +} + +private val SAMPLE_TITLES = listOf( + "Standup", + "Lunch", + "Review", + "Gym", + "Call", + "Workshop", + "Dentist", + "Trip", +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 0822035..3b1bc80 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.month import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth /** @@ -37,12 +39,85 @@ data class MonthWeek( val countByDay: Map, ) +/** + * The events occupying each lane of [day] — column [col] of this week — in lane + * order, capped at [laneCap] lanes. + * + * This is the same seating [spans]/[timedByDay] get when the paged grid draws a + * week: bars keep the lane the row layout gave them, and the day's timed events + * fill whatever slots are left, top-most first. Reading it here means the split + * style's dots and the paged style's bars describe a day in the *same order*, so + * dot _i_ and lane _i_ are the same event and one can morph into the other (#53). + * + * Deriving the dots independently — by distinct colour, as they first were — + * left dot _i_ standing for no particular event, and quietly merged two events + * that shared a calendar into a single dot. + */ +fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { + val byLane = arrayOfNulls(laneCap) + spans.forEach { span -> + if (span.lane < laneCap && col in span.startCol..span.endCol) { + byLane[span.lane] = span.event + } + } + val free = (0 until laneCap).filter { byLane[it] == null } + timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> + byLane[free[i]] = event + } + return byLane.filterNotNull() +} + +/** + * State for the continuous style (#38): a vertical stream of *self-contained* + * months rather than one undifferentiated run of weeks. Each month is keyed by + * absolute month index and carries only its own days — the boundary week keeps + * its seven columns so the grid geometry never shifts, but the neighbour month's + * dates are clipped out of it (see `clipWeekToMonth`) and render as blanks. + * + * The same loaded window is served two ways, because the Dense style is the same + * scroll with the seams taken out and it would be wasteful to query the provider + * twice for one range: + * + * - [monthsByIndex] — the block layout, keyed by absolute month index, each week + * clipped to its own month. + * - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary + * weeks left carrying both months. + * + * Both hold only the loaded window; indices outside it render as placeholders + * until the window catches up. + */ +sealed interface ContinuousMonthUiState { + data object Loading : ContinuousMonthUiState + data class Failure(val reason: FailureReason) : ContinuousMonthUiState + data class Success( + val today: LocalDate, + val monthsByIndex: Map>, + val weeksByIndex: Map, + val weekStart: DayOfWeek, + ) : ContinuousMonthUiState +} + sealed interface MonthUiState { data object Loading : MonthUiState data class Failure(val reason: FailureReason) : MonthUiState + + /** + * [weeks] is what the grid draws; [instancesByDay] is the same events keyed by + * date and *uncapped*, so the split style's day pane can list everything on a + * date without a second provider query — the month grid range already covers + * it. All-day events sort first, then by start. + */ data class Success( val month: YearMonth, val today: LocalDate, val weeks: List, + val instancesByDay: Map> = emptyMap(), + /** + * Travels on the state rather than being read at the call site, for the + * same reason the agenda does it: a file-level constant would be fixed + * for the process lifetime and drift from the zone the events were laid + * out in after a device time-zone change. + */ + val zone: TimeZone = TimeZone.currentSystemDefault(), ) : MonthUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 8abd2f0..c4cab41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -20,15 +20,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime +import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.toInstant @@ -66,6 +70,14 @@ class MonthViewModel @Inject constructor( initialValue = false, ) + /** How the grid is laid out and navigated (#38, #53). */ + val viewStyle: StateFlow = settingsPrefs.monthViewStyle + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = MonthViewStyle.Paged, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date @@ -73,8 +85,12 @@ class MonthViewModel @Inject constructor( val month: StateFlow = _month val state: StateFlow = - combine(_month, weekStart) { ym, ws -> ym to ws } - .flatMapLatest { (ym, ws) -> + combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) } + .flatMapLatest { (ym, ws, style) -> + // The mirror of the gate below: the paged grid is what Paged and + // Split draw, so under a scrolling style this query is a month + // of provider work for a view that isn't on screen. + if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading) val range = monthGridRange(ym, ws, zone) combine( repository.calendars(), @@ -91,21 +107,202 @@ class MonthViewModel @Inject constructor( initialValue = MonthUiState.Loading, ) + // --- Continuous + Dense styles (#38) ---------------------------------- + // + // These scroll through every month there is, so they can't load "a month" — + // they load a sliding window of month indices around whatever is on screen. + // The window only moves when the visible range comes within WINDOW_EDGE + // months of a loaded edge, so a scroll re-queries occasionally rather than + // on every frame. + // + // The window also *starts small and grows*. A provider Instances query + // expands recurrences across its whole range, so opening straight onto a + // year of months made the first frame wait for eleven months of expansion + // when only one was about to be looked at. It now opens on INITIAL_PAD + // months, and each completed load widens by GROWTH_STEP in both directions + // until MAX_PAD — the months you can reach by scrolling arrive while you're + // still looking at the first one. + + /** How far around the visible range we currently load; grows as loads land. */ + @Volatile + private var loadPad = INITIAL_PAD + + /** Last reported visible range, so a widening step can re-centre on it. */ + @Volatile + private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { it..it } + + private val _loadedMonths = MutableStateFlow( + monthIndexOf(YearMonth(todayDate.year, todayDate.month)) + .let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) }, + ) + + val continuousState: StateFlow = + combine(_loadedMonths, weekStart, viewStyle) { window, ws, style -> + Triple(window, ws, style) + } + .flatMapLatest { (window, ws, style) -> + // Nothing to load for the paged and split styles — they have + // their own single-month flow, and querying a year of months + // behind them is pure waste. + if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading) + // Widened to whole grid weeks at both ends: a month block still + // has to know about an event that starts in the boundary week's + // clipped-off days, or a bar running into the block would vanish. + val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws) + val last = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + .startOfGridWeek(ws) + .plus(6, DateTimeUnit.DAY) + val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildContinuousState(window, ws, style, calendars, instances) + } + } + // A load landing is the cue to reach further out. Widening from here + // rather than on a timer means each step waits for the previous one, + // so the ladder can never outrun the provider. + .onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() } + .catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = ContinuousMonthUiState.Loading, + ) + + /** + * Report which absolute month indices are on screen. Cheap to call on every + * scroll frame: it returns immediately unless the visible range has drifted + * close enough to a loaded edge to warrant a wider query. + */ + fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) { + visibleMonths = firstIndex..lastIndex + nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad) + ?.let { _loadedMonths.value = clampMonthWindow(it) } + } + + /** + * One rung up the ladder: reach [GROWTH_STEP] further in each direction, + * stopping at [MAX_PAD]. A no-op once there, so the provider notifications + * that re-emit the same window don't restart it. + */ + private fun widenLoadedWindow() { + if (loadPad >= MAX_PAD) return + loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD) + _loadedMonths.value = clampMonthWindow( + visibleMonths.first - loadPad..visibleMonths.last + loadPad, + ) + } + + private fun buildContinuousState( + window: IntRange, + weekStart: DayOfWeek, + style: MonthViewStyle, + calendars: List, + instances: List, + ): ContinuousMonthUiState { + if (calendars.isEmpty()) { + return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured) + } + // Bucketed once for the whole window: a row then takes the handful of + // events on its seven days instead of re-scanning a year of them. + val byDay = DayIndex(instances, zone) + // Only the style on screen is laid out. Building both doubled the work + // for a layout nothing was going to draw. + val months = if (style == MonthViewStyle.Continuous) { + window.associateWith { index -> + val ym = yearMonthForIndex(index) + layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) } + } + } else { + emptyMap() + } + val weeks = if (style == MonthViewStyle.Dense) { + weekWindowFor(window, weekStart).associateWith { index -> + val days = (0 until 7).map { + weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY) + } + layoutCalendarWeek(days, byDay.eventsOn(days), zone) + } + } else { + emptyMap() + } + return ContinuousMonthUiState.Success( + today = todayDate, + monthsByIndex = months, + weeksByIndex = weeks, + weekStart = weekStart, + ) + } + + // --- 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. + + private val _selectedDate = MutableStateFlow(todayDate) + val selectedDate: StateFlow = _selectedDate + + /** + * Select [date], following it to its month if it sits in the grid's leading + * or trailing days — tapping a greyed-out day should show that day, not + * silently list a date the grid isn't pointing at. + */ + fun selectDate(date: LocalDate) { + _selectedDate.value = date + val target = YearMonth(date.year, date.month) + 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. + */ + private fun realignSelection() { + _selectedDate.value = selectionForMonth(_month.value, todayDate) + } + fun goToPrev() { _month.value = _month.value.minus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToNext() { _month.value = _month.value.plus(1, DateTimeUnit.MONTH) + realignSelection() } fun goToToday() { _month.value = YearMonth(todayDate.year, todayDate.month) + _selectedDate.value = todayDate + } + + /** + * Track [_month] to the month a scrolling style is showing. Those styles + * navigate by scroll position and never set [_month] themselves, so it would + * 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. + */ + fun syncScrollMonth(ym: YearMonth) { + if (_month.value == ym) return + _month.value = ym + _selectedDate.value = selectionForMonth(ym, todayDate) } /** Jump to the month containing [date] (drawer jump-to-date). */ fun goToDate(date: LocalDate) { _month.value = YearMonth(date.year, date.month) + _selectedDate.value = date } private fun buildState( @@ -117,10 +314,13 @@ class MonthViewModel @Inject constructor( if (calendars.isEmpty()) { return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) } + val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) return MonthUiState.Success( month = ym, today = todayDate, - weeks = layoutMonthWeeks(ym, weekStart, instances, zone), + weeks = weeks, + instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone), + zone = zone, ) } } @@ -139,41 +339,124 @@ internal fun layoutMonthWeeks( weekStart: DayOfWeek, instances: List, zone: TimeZone, +): List = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone) + +/** + * As above, but reusing a [DayIndex] already built for a wider range — what the + * scrolling styles need, since they lay out many months from one query and + * rebuilding the index per month would put the cost straight back. + */ +internal fun layoutMonthWeeks( + ym: YearMonth, + weekStart: DayOfWeek, + byDay: DayIndex, + zone: TimeZone, ): List { - val firstOfMonth = LocalDate(ym.year, ym.month, 1) - val gridStart = firstOfMonth.startOfGridWeek(weekStart) - val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 - val daysInMonth = - java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() - val weekCount = (leadOffset + daysInMonth + 6) / 7 + val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart) + val weekCount = weekRowsInMonth(ym, weekStart) return (0 until weekCount).map { row -> val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) } - val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } - val (bars, singles) = weekEvents.partition { ev -> - ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1 - } - val spans = layoutAllDay(bars, days, zone).map { s -> - MonthSpan( - event = s.event, - startCol = s.startCol, - endCol = s.endCol, - lane = s.lane, - continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone), - continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone), - ) - } - MonthWeek( - days = days, - spans = spans, - timedByDay = days.associateWith { d -> - singles.filter { it.coversDay(d, zone) }.sortedBy { it.start } - }, - countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, - ) + layoutCalendarWeek(days, byDay.eventsOn(days), zone) } } +/** + * Events bucketed by the dates they cover, built once per load. + * + * [layoutCalendarWeek] opens by filtering the instances it was handed down to + * the ones touching its seven days. That is fine for a single month's grid, but + * the scrolling styles lay out a hundred-odd rows from one query, and scanning + * *every* instance in an eleven-month window for each of them made the work grow + * with the number of enabled calendars until opening the view stalled. + * + * Membership is decided by [coversDay] itself rather than by re-deriving the + * rule from the timestamps: the all-day and timed cases have enough edge cases + * between them (UTC anchoring, exclusive ends, zero-length events at midnight) + * that a second implementation would drift. The walk only visits an event's own + * candidate days, so the check runs a couple of times per event rather than once + * per event per row. + */ +internal class DayIndex(instances: List, private val zone: TimeZone) { + + private val byDay: Map> = buildMap> { + instances.forEach { event -> + // All-day events are anchored to UTC midnights; timed ones to the + // device zone. Either way the candidate span is the event's own + // dates, which is one or two days for almost everything. + val anchor = if (event.isAllDay) TimeZone.UTC else zone + var day = event.start.toLocalDateTime(anchor).date + val lastCandidate = event.end.toLocalDateTime(anchor).date + while (day <= lastCandidate) { + if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event) + day = day.plus(1, DateTimeUnit.DAY) + } + } + } + + /** Every event touching any of [days], each listed once, in input order. */ + fun eventsOn(days: List): List { + if (days.isEmpty()) return emptyList() + val hits = days.flatMap { byDay[it].orEmpty() } + // A multi-day event is bucketed on each date it covers, so a row that + // holds several of its days would otherwise list it several times. + return if (hits.size < 2) hits else hits.distinctBy { it.instanceId } + } +} + +/** + * Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so + * the continuous style — which streams weeks rather than months and so has no + * enclosing [YearMonth] to slice by — lays each row out identically. + * + * [days] must be the row's seven consecutive dates, in display order. + */ +internal fun layoutCalendarWeek( + days: List, + instances: List, + zone: TimeZone, +): MonthWeek { + val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } + val (bars, singles) = weekEvents.partition { ev -> + ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1 + } + val spans = layoutAllDay(bars, days, zone).map { s -> + MonthSpan( + event = s.event, + startCol = s.startCol, + endCol = s.endCol, + lane = s.lane, + continuesLeft = s.event.coversDay(days.first().minus(1, DateTimeUnit.DAY), zone), + continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone), + ) + } + return MonthWeek( + days = days, + spans = spans, + timedByDay = days.associateWith { d -> + singles.filter { it.coversDay(d, zone) }.sortedBy { it.start } + }, + countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, + ) +} + +/** + * Every event touching each of [days], all-day first then by start time. Unlike + * [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date + * they cover and applies no display cap, so the split style's day pane can list a + * date in full without querying the provider again. + */ +internal fun instancesByDay( + days: List, + instances: List, + zone: TimeZone, +): Map> = + days.associateWith { day -> + instances + .filter { it.coversDay(day, zone) } + .sortedWith(compareByDescending { it.isAllDay }.thenBy { it.start }) + } + /** * The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the * trailing days of the previous month and the leading days of the next month. @@ -191,6 +474,198 @@ internal fun monthGridRange( return start..end } +/** + * The sliding window's shape. + * + * [INITIAL_PAD] is what the first frame waits for — one month either side of the + * visible one, so opening the view costs about what the paged style costs. Each + * completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling + * in the months a scroll could reach while the first ones are already on screen. + * + * [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before + * it reloads. It is always kept below the current pad — a trigger at or beyond + * the pad would re-fire the moment its own reload landed. + */ +private const val INITIAL_PAD = 1 +private const val GROWTH_STEP = 2 +private const val MAX_PAD = 5 +private const val WINDOW_EDGE = 2 + +/** The reload trigger for a given pad, held strictly inside it. */ +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. + */ +internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate = + if (YearMonth(today.year, today.month) == month) { + today + } else { + LocalDate(month.year, month.month, 1) + } + +/** + * The window to load for a visible range, or null to keep the current one. + * + * Kept pure and separate from the view model so the hysteresis — the reason a + * scroll doesn't re-query the provider on every frame — is testable on its own. + */ +internal fun nextLoadWindow( + loaded: IntRange, + firstVisible: Int, + lastVisible: Int, + pad: Int = MAX_PAD, +): IntRange? { + val edge = edgeForPad(pad) + val comfortablyInside = + firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last + if (comfortablyInside) return null + return (firstVisible - pad)..(lastVisible + pad) +} + +/** + * The continuous grid addresses months by an absolute index, so the list has one + * stable, gap-free coordinate space to scroll through and key its items by. + * Index 0 is January 1900; the list runs to [continuousMonthCount]. + * + * Unlike the week indexing this replaced, the coordinate space doesn't depend on + * the week-start preference — changing it reflows the rows *inside* a month + * block but never moves the block, so a scroll position stays put. + */ +private const val MONTH_INDEX_EPOCH_YEAR = 1900 +private const val MONTH_INDEX_END_YEAR = 2100 + +internal fun monthIndexOf(ym: YearMonth): Int = + (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal + +internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( + MONTH_INDEX_EPOCH_YEAR + index / 12, + Month.entries[index % 12], +) + +/** Total months the continuous grid scrolls through (1900 → 2100). */ +internal fun continuousMonthCount(): Int = + (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 + +/** + * Each month occupies two LazyColumn items — its sticky header and the block of + * week rows under it — so the two coordinate spaces differ by a factor of two. + * Scrolling to a month means scrolling to its header. + */ +internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2 + +internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 + +/** + * Hold a load window inside the months that actually exist. The pad + * [nextLoadWindow] adds can otherwise push an index past either end of the + * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + */ +internal fun clampMonthWindow(window: IntRange): IntRange { + val last = continuousMonthCount() - 1 + return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) +} + +internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1) + +/** + * The Dense style addresses *weeks* by absolute index instead: it has no month + * blocks to key by, and a row's identity has to survive the months around it + * scrolling past. Index 0 is the first week of 1900 under the active week-start, + * so — like the month space — every index is non-negative and the LazyColumn's + * item indices and week indices are the same number. + * + * Unlike month indices these *do* shift with the week-start preference, which is + * why the list state is keyed on it. + */ +private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) +private val WEEK_INDEX_END = LocalDate(2100, 12, 31) + +internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { + val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart) + return base.daysUntil(date.startOfGridWeek(weekStart)) / 7 +} + +internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = + WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) + +/** Total weeks the Dense grid scrolls through (1900 → 2100). */ +internal fun continuousWeekCount(weekStart: DayOfWeek): Int = + weekIndexOf(WEEK_INDEX_END, weekStart) + 1 + +/** Which month a Dense row belongs to, for reporting the visible range. */ +internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int { + // The row's midweek day: a boundary week is reported as whichever month owns + // most of it, the same rule the title uses. + val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY) + return monthIndexOf(YearMonth(midweek.year, midweek.month)) +} + +/** + * Every week index the months in [window] touch — the Dense rows one month-window + * load covers. Widened by a week at each end so the boundary rows either side are + * laid out too, rather than flickering as placeholders. + */ +internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange { + val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1 + val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last)) + .plus(1, DateTimeUnit.MONTH) + .minus(1, DateTimeUnit.DAY) + return first..(weekIndexOf(lastMonthEnd, weekStart) + 1) +} + +/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */ +internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int { + val first = firstOfMonth(ym) + val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth() + return (leadOffset + daysInMonth + 6) / 7 +} + +/** + * Strip everything outside [month] from a boundary week row, for the continuous + * style's self-contained month blocks. + * + * The seven [MonthWeek.days] stay put — the row keeps its column geometry, and + * the grid decides which cells to draw blank — but the neighbour month's events + * are removed: its pills and counts go, and a bar reaching in from (or out into) + * it is cut back to the month's own columns. A bar cut this way keeps a flat + * cap, which is what says "this continues past the block" rather than "it ends + * here". + */ +internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek { + val inMonth = week.days.map { it.year == month.year && it.month == month.month } + val firstCol = inMonth.indexOfFirst { it } + val lastCol = inMonth.indexOfLast { it } + // Can't happen for a row of the month's own grid, but a caller-proof no-op + // beats an out-of-range crash. + if (firstCol == -1) { + return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap()) + } + val spans = week.spans.mapNotNull { span -> + val start = maxOf(span.startCol, firstCol) + val end = minOf(span.endCol, lastCol) + if (start > end) { + null + } else { + span.copy( + startCol = start, + endCol = end, + continuesLeft = span.continuesLeft || start > span.startCol, + continuesRight = span.continuesRight || end < span.endCol, + ) + } + } + val own = week.days.filterIndexed { col, _ -> inMonth[col] } + return week.copy( + spans = spans, + timedByDay = week.timedByDay.filterKeys { it in own }, + countByDay = week.countByDay.filterKeys { it in own }, + ) +} + internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt new file mode 100644 index 0000000..2f553c8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.annotation.StringRes +import de.jeanlucmakiola.calendula.R + +/** + * How the Month view lays itself out (#38, #53). + * + * One setting rather than two independent toggles: "vertical scrolling" and + * "month plus agenda" would otherwise multiply into four combinations to build + * and test, most of which nobody asked for. + * + * [Split] does **not** replace or disable the Agenda view — that stays a forward + * multi-day window with its own range model, while the split pane lists a single + * selected day. + */ +enum class MonthViewStyle { + /** Month pages, swiped left/right. Full event bars and pills per day. */ + Paged, + + /** + * Months stacked into one vertical scroll, each a self-contained block under + * its own sticky header: a block shows only its own days, and whitespace + * separates it from the next (#38). + */ + Continuous, + + /** + * [Continuous] with the seams taken out: one uninterrupted stream of weeks + * where months flow into each other. Each date appears exactly once — paging + * repeats a boundary week at both ends — and the 1st names its month, the + * only marker of where one ends. + */ + Dense, + + /** Compact dots-only grid over a list of the selected day's events (#53). */ + Split, +} + +/** Both vertically scrolling styles, which share a data window and a list state. */ +val MonthViewStyle.isScrolling: Boolean + get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense + +@get:StringRes +val MonthViewStyle.labelRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged + MonthViewStyle.Continuous -> R.string.month_style_continuous + MonthViewStyle.Dense -> R.string.month_style_dense + MonthViewStyle.Split -> R.string.month_style_split + } + +@get:StringRes +val MonthViewStyle.descriptionRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged_summary + MonthViewStyle.Continuous -> R.string.month_style_continuous_summary + MonthViewStyle.Dense -> R.string.month_style_dense_summary + MonthViewStyle.Split -> R.string.month_style_split_summary + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt new file mode 100644 index 0000000..53101bf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.PickerDescription +import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.ui.month.descriptionRes +import de.jeanlucmakiola.calendula.ui.month.labelRes +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import kotlinx.datetime.DayOfWeek + +/** + * The Month view style chooser (#38, #53). + * + * A live, scaled-down Month view sits above the options and changes as you pick + * one, so the choice is made by looking rather than by reading "Continuous" and + * guessing. Selecting therefore applies immediately and leaves the picker open — + * closing on tap would hide the very thing the screen is for. Back exits, the + * same as the App name picker, which stays open for the same reason. + * + * Under the preview sits the selected style's own one-line blurb, then the + * family's standard picker shape: connected single-line grouped rows with a + * tonal highlight and [SelectedCheck] on the current one. + */ +@Composable +internal fun MonthViewStylePicker( + selected: MonthViewStyle, + weekStart: DayOfWeek, + onSelect: (MonthViewStyle) -> Unit, + onDismiss: () -> Unit, +) { + val options = MonthViewStyle.entries + val reduceMotion = rememberReduceMotion() + FullScreenPicker( + title = stringResource(R.string.settings_month_view_style), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .height(PREVIEW_HEIGHT), + contentAlignment = Alignment.Center, + ) { + Crossfade( + targetState = selected, + animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250), + label = "month-style-preview", + ) { shown -> + // The preview renders the real grid at phone size and scales it + // down, so its own corners are square — the frame rounds it. + Box( + modifier = Modifier + .clip(PREVIEW_SHAPE) + .background(MaterialTheme.colorScheme.surface) + .clipToBounds(), + ) { + MonthStylePreview( + style = shown, + weekStart = weekStart, + height = PREVIEW_HEIGHT, + ) + } + } + } + // The selected style's own blurb lives here, under its preview, rather + // than on every row — the rows stay single-line and dense, and the words + // describe exactly what is being shown above. + PickerDescription(stringResource(selected.descriptionRes)) + options.forEachIndexed { index, style -> + val isSelected = style == selected + GroupedRow( + title = stringResource(style.labelRes), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + // Applies straight away; the preview above is the confirmation, + // so there is nothing to dismiss for. + onClick = { onSelect(style) }, + ) + } + } +} + +private val PREVIEW_HEIGHT = 280.dp +private val PREVIEW_SHAPE = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f58c743..c490375 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.qs.NewEventTileService @@ -125,6 +126,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.floret.components.ReorderableColumn import de.jeanlucmakiola.floret.components.ReorderableRowHeight import de.jeanlucmakiola.calendula.ui.common.icon @@ -847,12 +849,24 @@ private fun ViewsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, ) { + var showMonthStyle by remember { mutableStateOf(false) } + CollapsingScaffold( title = stringResource(R.string.settings_section_views), onBack = onBack, ) { val config = state.quickSwitchConfig + // Per-view layout, above the cross-view switcher/order settings below. + SectionHeader(stringResource(R.string.settings_month_header)) + GroupedRow( + title = stringResource(R.string.settings_month_view_style), + summary = stringResource(state.monthViewStyle.labelRes), + position = Position.Alone, + onClick = { showMonthStyle = true }, + ) + + Spacer(Modifier.height(24.dp)) SectionHeader(stringResource(R.string.settings_quick_switch_header)) SettingsHint(stringResource(R.string.settings_quick_switch_hint)) Spacer(Modifier.height(8.dp)) @@ -898,6 +912,16 @@ private fun ViewsScreen( ) } } + + if (showMonthStyle) { + MonthViewStylePicker( + selected = state.monthViewStyle, + // The preview is a real grid, so it uses the real week start too. + weekStart = state.weekStart.resolveFirstDay(currentLocale()), + onSelect = viewModel::setMonthViewStyle, + onDismiss = { showMonthStyle = false }, + ) + } } /** One reorderable view row: the view's icon and name, an optional [trailing] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 4fbb436..8f59b2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,6 +52,8 @@ data class SettingsUiState( val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, + /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ + val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index de3bcbe..a549089 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY @@ -150,8 +151,12 @@ class SettingsViewModel @Inject constructor( prefs.dimCompletedEvents, // View customisation (#24) folded into one flow so it fits this // group — the outer combine is already at its five-arg limit. - combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer -> - ViewCustomization(quickSwitch, drawer) + combine( + prefs.quickSwitchConfig, + prefs.drawerViewOrder, + prefs.monthViewStyle, + ) { quickSwitch, drawer, monthStyle -> + ViewCustomization(quickSwitch, drawer, monthStyle) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -173,6 +178,7 @@ class SettingsViewModel @Inject constructor( dimCompletedEvents = misc.dimCompletedEvents, quickSwitchConfig = misc.viewCustomization.quickSwitch, drawerViewOrder = misc.viewCustomization.drawerOrder, + monthViewStyle = misc.viewCustomization.monthViewStyle, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -286,6 +292,7 @@ class SettingsViewModel @Inject constructor( private data class ViewCustomization( val quickSwitch: QuickSwitchConfig, val drawerOrder: List, + val monthViewStyle: MonthViewStyle, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } } } + fun setMonthViewStyle(style: MonthViewStyle) { + viewModelScope.launch { prefs.setMonthViewStyle(style) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 88b9a14..ea5243c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -94,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -296,8 +295,6 @@ private fun WeekContent( modifier: Modifier = Modifier, ) { val density = LocalDensity.current - val threshold = with(density) { 24.dp.toPx() } - var dragAccum by remember { mutableFloatStateOf(0f) } val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -329,20 +326,7 @@ private fun WeekContent( // vertical scroll: a horizontal drag only crosses *this* detector's slop, // while a vertical drag is consumed by the inner scroll first — so the two // gestures coexist without fighting. - val swipeModifier = Modifier.pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { dragAccum = 0f }, - onDragEnd = { - when { - dragAccum < -threshold -> onSwipeNext() - dragAccum > threshold -> onSwipePrev() - } - dragAccum = 0f - }, - onDragCancel = { dragAccum = 0f }, - onHorizontalDrag = { _, drag -> dragAccum += drag }, - ) - } + val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev) AnimatedContent( targetState = state, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7ff4e1..ef9bfce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,20 @@ %d days Views + + Month view + Month view style + Pages + One month fills the screen. Swipe left or right to change month. + Scrolling months + Each month sits under its own heading, with a little space setting it apart from the next. + Seamless weeks + The weeks run on without a break, each month flowing straight into the next with no gap between them. + Split + A compact grid marks the days that have events, and the day you tap is listed underneath. + Nothing scheduled + Show the whole month + Show the day\'s events Quick-switch button Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. Navigation menu @@ -411,7 +425,7 @@ Add or improve a language on Weblate Theme, default view, week start - Quick-switch button and menu order + Month layout, quick-switch button, menu order Default fields for new events Event reminders Contact birthdays & anniversaries diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 5b53234..7693801 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -125,6 +126,30 @@ class SettingsPrefsTest { assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } + @Test + fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + + prefs.setMonthViewStyle(MonthViewStyle.Split) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split) + + prefs.setMonthViewStyle(MonthViewStyle.Continuous) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous) + } + + @Test + fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel" + m + } + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt new file mode 100644 index 0000000..3193ded --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What makes the continuous style's month blocks self-contained: a boundary week + * keeps its seven columns but carries only the block's own month. + */ +class ClipWeekToMonthTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */ + private fun firstRowOfJuly(events: List) = + clipWeekToMonth( + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(), + jul26, + ) + + @Test + fun `the row keeps all seven days so the columns don't shift`() { + val week = firstRowOfJuly(emptyList()) + assertThat(week.days).hasSize(7) + assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29)) + } + + @Test + fun `the neighbour month's events are dropped`() { + val june = timed(LocalDate(2026, 6, 30), 9) + val july = timed(LocalDate(2026, 7, 1), 9, id = 3L) + val week = firstRowOfJuly(listOf(june, july)) + + assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30)) + assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july) + assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull() + assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1) + } + + @Test + fun `a bar reaching in from the previous month is cut back to the 1st`() { + // Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row. + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2)))) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st + assertThat(span.endCol).isEqualTo(3) + // Flat cap on the cut side: the event continues out of this block. + assertThat(span.continuesLeft).isTrue() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a bar living entirely in the neighbour month disappears`() { + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30)))) + assertThat(week.spans).isEmpty() + } + + @Test + fun `a bar reaching out into the next month is cut at the last day`() { + // Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2). + val weeks = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))), + zone, + ) + val span = clipWeekToMonth(weeks.last(), jul26).spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th + assertThat(span.endCol).isEqualTo(4) // Friday the 31st + assertThat(span.continuesRight).isTrue() + assertThat(span.continuesLeft).isFalse() + } + + @Test + fun `a row wholly inside the month is untouched`() { + val mid = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))), + zone, + )[1] + assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt new file mode 100644 index 0000000..ef2c56b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -0,0 +1,238 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** + * The continuous grid's coordinate space. Every block's identity — and the + * LazyColumn items it maps to — hangs off this arithmetic, so it gets its own + * tests rather than being exercised only through the UI. + */ +class ContinuousMonthIndexTest { + + private val jun26 = YearMonth(2026, Month.JUNE) + + @Test + fun `consecutive months are consecutive indices`() { + val jun = monthIndexOf(jun26) + assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1) + assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1) + // And across the year boundary. + assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY))) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1) + } + + @Test + fun `index round-trips back to its month`() { + listOf( + YearMonth(1900, Month.JANUARY), + jun26, + YearMonth(2026, Month.DECEMBER), + YearMonth(2100, Month.DECEMBER), + ).forEach { ym -> + assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) + } + } + + @Test + fun `indices are non-negative and span 1900 through 2100`() { + assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + assertThat(monthIndexOf(jun26)).isGreaterThan(0) + assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + .isEqualTo(continuousMonthCount() - 1) + assertThat(continuousMonthCount()).isEqualTo(201 * 12) + } + + @Test + fun `a month maps to its header item and back`() { + val index = monthIndexOf(jun26) + val item = itemIndexForMonth(index) + // Both of a month's items — its sticky header and the block under it — + // resolve back to the same month, so a title read off the first visible + // item is right wherever the viewport sits inside the block. + assertThat(monthIndexForItem(item)).isEqualTo(index) + assertThat(monthIndexForItem(item + 1)).isEqualTo(index) + assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1) + } + + @Test + fun `week rows follow the month's shape and its week start`() { + // June 2026 starts on a Monday: 30 days → 5 rows either way. + assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5) + assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5) + // February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4 + // rows, a Monday-anchored one needs 5 — the block's height moves with the + // preference even though its position in the list doesn't. + val feb26 = YearMonth(2026, Month.FEBRUARY) + assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4) + assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5) + // February 2021 starts on a Monday and is 28 days → exactly 4. + assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY)) + .isEqualTo(4) + } + + @Test + fun `the placeholder block is the size the loaded one will be`() { + // Otherwise scrolling across an unloaded month jumps when its data lands. + (0 until 24).forEach { offset -> + val ym = yearMonthForIndex(monthIndexOf(jun26) + offset) + DayOfWeek.entries.forEach { ws -> + assertThat(weekRowsInMonth(ym, ws)) + .isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size) + } + } + } + + @Test + fun `a window is clamped to the months that exist`() { + // The regression this guards: an empty visible-items list reported as + // "month 0", whose padded window ran to -4 — and yearMonthForIndex has + // no month for that, so the whole grid failed with "couldn't read". + assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4) + val last = continuousMonthCount() - 1 + assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last) + assertThat(clampMonthWindow(10..20)).isEqualTo(10..20) + } + + @Test + fun `every index a clamped window can hold resolves to a month`() { + listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3))) + .forEach { window -> + window.forEach { index -> yearMonthForIndex(index) } + } + } + + @Test + fun `the dense week window covers every week its months touch`() { + // One month-window load has to lay out every Dense row those months + // appear in, boundary weeks included, or rows would flicker as + // placeholders while the data for them is already in hand. + DayOfWeek.entries.forEach { ws -> + val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST)) + val weeks = weekWindowFor(window, ws) + window.forEach { index -> + val ym = yearMonthForIndex(index) + val first = weekIndexOf(firstOfMonth(ym), ws) + val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) + assertThat(weeks.contains(first)).isTrue() + assertThat(weeks.contains(last)).isTrue() + } + } + } + + @Test + fun `a dense row reports the month that owns most of it`() { + // June 29 – July 5, 2026 (Monday-anchored): four of its days are July's, + // so the window follows July rather than snapping back to June. + val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY) + assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY))) + // A row wholly inside June reports June. + assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(jun26)) + } + + @Test + fun `every day of a dense week shares one index`() { + val mon = LocalDate(2026, 6, 8) + val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(indices.toSet()).hasSize(1) + } + + @Test + fun `dense week indices round-trip and stay inside the list`() { + val wed = LocalDate(2026, 6, 10) + DayOfWeek.entries.forEach { ws -> + val index = weekIndexOf(wed, ws) + assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) + assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(index).isLessThan(continuousWeekCount(ws)) + assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + .isLessThan(continuousWeekCount(ws)) + } + } + + @Test + fun `firstOfMonth is the 1st`() { + assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) + } + + @Test + fun `a window comfortably around the visible range is kept`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() + } + + @Test + fun `nearing a loaded edge widens the window around the visible range`() { + // Within two months of the top edge → reload, padded on both sides. + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) + .isEqualTo(-4..7) + + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) + .isEqualTo(93..105) + } + + @Test + fun `a jump far outside the window reloads around the destination`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) + .isEqualTo(495..506) + } + + @Test + fun `the reloaded window always clears the trigger it just crossed`() { + // Otherwise every scroll frame would re-trigger a query — and with the + // window growing from a pad of 1, this has to hold at every rung of the + // ladder, not just the widest one. + (1..8).forEach { pad -> + val loaded = (10 - pad)..(10 + pad) + val past = 10 + pad + 1 + val widened = nextLoadWindow(loaded, past, past, pad)!! + assertThat(nextLoadWindow(widened, past, past, pad)).isNull() + } + } + + @Test + fun `the reload trigger stays inside the pad`() { + // A trigger at or beyond the pad would fire again the instant its own + // reload landed, and the window would query forever. + (1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) } + // A one-month window has no room for hysteresis: reload only on contact. + assertThat(edgeForPad(1)).isEqualTo(0) + assertThat(edgeForPad(0)).isEqualTo(0) + } + + @Test + fun `the smallest window reloads on crossing rather than nearing its edge`() { + // The first frame loads one month either side of today, which leaves no + // room to reload *before* the edge: at pad 1 the trigger is contact. + // Only momentary — the first completed load widens the pad to 3, which + // buys the usual head start back. + val initial = 10..12 + assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull() + assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1)) + .isEqualTo(12..14) + } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt new file mode 100644 index 0000000..d71eb19 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * [DayIndex] exists purely to make the scrolling styles cheap, so what it owes + * is that cheapness changes *nothing*: a row laid out from the index must be the + * row that would have come from scanning every instance. + * + * Run in a zone east of UTC, where the all-day anchoring and the day boundaries + * disagree — the case that has bitten this codebase before (#65). + */ +class DayIndexTest { + + private val zone = TimeZone.of("Europe/Berlin") + private val mon = LocalDate(2026, 6, 8) + private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) } + + private var nextId = 0L + + private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = from.atTime(startHour, 0).toInstant(zone), + end = to.atTime(endHour, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + } + + private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + } + + /** Every shape the layout distinguishes, plus the boundary cases around them. */ + private fun awkwardEvents(): List = listOf( + timed(mon, 9, mon, 10), + // Crosses midnight into the next day. + timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1), + // Ends exactly at midnight: covers its own day, not the next. + timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0), + // Zero length, at midnight: covers nothing at all. + timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0), + allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)), + // Runs in from before the row and out past its end. + allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)), + // Wholly outside the row, both sides. + timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10), + allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)), + // Two on one day, to pin the ordering the index hands back. + timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9), + timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15), + ) + + @Test + fun `a row from the index is the row from a full scan`() { + val events = awkwardEvents() + val scanned = layoutCalendarWeek(week, events, zone) + val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone) + assertThat(indexed).isEqualTo(scanned) + } + + @Test + fun `a whole month agrees, row for row`() { + val events = awkwardEvents() + val jun = YearMonth(2026, Month.JUNE) + DayOfWeek.entries.forEach { ws -> + assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone)) + .isEqualTo(layoutMonthWeeks(jun, ws, events, zone)) + } + } + + @Test + fun `an event covering several of a row's days is listed once`() { + // It is bucketed on each date it covers, so the row would otherwise see + // it repeatedly and lay out a lane per copy. + val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY)) + val events = DayIndex(listOf(long), zone).eventsOn(week) + assertThat(events).containsExactly(long) + } + + @Test + fun `days no event touches come back empty`() { + val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone) + assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty() + assertThat(index.eventsOn(emptyList())).isEmpty() + } + + @Test + fun `an empty calendar indexes to nothing`() { + assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt new file mode 100644 index 0000000..0374ab3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What lets the split style's dots morph into the paged style's bars (#53): both + * read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event. + */ +class LaneEventsTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1] + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) = + EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(zone), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = color, + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = color, + location = null, + ) + + @Test + fun `a bar keeps its lane and the day's timed events fill what's left`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, meeting)) + + // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + .containsExactly(bar, meeting) + .inOrder() + } + + @Test + fun `a day the bar misses seats its own events from lane zero`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, monday)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(monday) + } + + @Test + fun `a multi-day bar is seated on every day it covers`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + (1..3).forEach { col -> + val day = LocalDate(2026, 7, 6 + col) + assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + } + assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + } + + /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ + @Test + fun `events sharing a colour each keep their own lane`() { + val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED) + val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) + val week = rowOfJuly6(listOf(first, second)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(first, second) + .inOrder() + } + + @Test + fun `seating stops at the cap and leaves the rest to the overflow count`() { + val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } + val week = rowOfJuly6(events) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) + } + + /** A bar parked below the cap is out of view, so it takes no dot with it. */ + @Test + fun `a bar beyond the cap is left out`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) + .containsNoneIn(seated) + } + + private companion object { + const val BLUE = 0xFF3366CC.toInt() + const val RED = 0xFFCC3333.toInt() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt new file mode 100644 index 0000000..52e43f8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -0,0 +1,220 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class MonthLayoutTest { + + private val zone = TimeZone.UTC + + // 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st. + private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE) + private val jun1 = LocalDate(2026, 6, 1) + private val jun8 = LocalDate(2026, 6, 8) + private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) } + + private fun at(date: LocalDate, h: Int, m: Int = 0): Instant = + date.atTime(h, m).toInstant(zone) + + private fun timed( + date: LocalDate, + startHour: Int, + endHour: Int, + id: Long = 1L, + title: String = "E", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = at(date, startHour), + end = at(date, endHour), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** All-day events live at UTC midnights with an *exclusive* end. */ + private fun allDay( + from: LocalDate, + toInclusive: LocalDate = from, + id: Long = 100L, + title: String = "A", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + @Test + fun `startOfGridWeek snaps back to the configured week start`() { + val wed = LocalDate(2026, 6, 10) + assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + // A Sunday-anchored week containing the 10th starts on the 7th. + assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7)) + } + + @Test + fun `monthGridRange always covers 42 days from the grid start`() { + val range = monthGridRange(jun, DayOfWeek.MONDAY, zone) + assertThat(range.start).isEqualTo(at(jun1, 0)) + // 42 days inclusive → the last second of 2026-07-12. + assertThat(range.endInclusive) + .isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone)) + } + + @Test + fun `week count follows the month's shape rather than a fixed six rows`() { + // June 2026: starts Monday, 30 days → 5 rows. + assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5) + // August 2026: starts Saturday, 31 days → spills to 6 rows. + val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST) + assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6) + // February 2021: starts Monday, 28 days → exactly 4 rows. + val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY) + assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4) + } + + @Test + fun `layoutMonthWeeks rows are contiguous and seven days wide`() { + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone) + assertThat(weeks.first().days.first()).isEqualTo(jun1) + weeks.forEach { assertThat(it.days).hasSize(7) } + val allDays = weeks.flatMap { it.days } + allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) } + } + + @Test + fun `a multi-day event becomes one span across its columns`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + assertThat(week.spans).hasSize(1) + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th + assertThat(span.endCol).isEqualTo(4) // Friday the 12th + assertThat(span.lane).isEqualTo(0) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a span running past the row end is flagged as continuing`() { + // Saturday the 13th through Tuesday the 16th straddles the row boundary. + val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(5) + assertThat(span.endCol).isEqualTo(6) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isTrue() + + // The following row picks it up with the flags mirrored. + val nextRow = layoutCalendarWeek( + weekOf8th.map { it.plus(7, DateTimeUnit.DAY) }, + listOf(ev), + zone, + ) + val tail = nextRow.spans.single() + assertThat(tail.startCol).isEqualTo(0) + assertThat(tail.endCol).isEqualTo(1) + assertThat(tail.continuesLeft).isTrue() + assertThat(tail.continuesRight).isFalse() + } + + @Test + fun `overlapping spans are stacked on separate lanes`() { + val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L) + val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone) + + assertThat(week.spans.map { it.lane }).containsExactly(0, 1) + } + + @Test + fun `single-day timed events stay pills and sort by start`() { + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone) + + assertThat(week.spans).isEmpty() + assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId }) + .containsExactly(2L, 1L) + .inOrder() + assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() + } + + @Test + fun `countByDay totals bars and pills on each date`() { + val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) + val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone) + + assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2) + assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1) + assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0) + } + + @Test + fun `layoutMonthWeeks agrees with laying each row out on its own`() { + val events = listOf( + allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L), + timed(LocalDate(2026, 6, 18), 9, 10, id = 2L), + ) + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone) + + weeks.forEach { row -> + assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone)) + } + } + + @Test + fun `instancesByDay puts all-day events first then orders by start`() { + val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L) + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L) + + val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId }) + .containsExactly(1L, 3L, 2L) + .inOrder() + } + + @Test + fun `instancesByDay repeats a multi-day event on every date it covers`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val byDay = instancesByDay(weekOf8th, listOf(ev), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty() + assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty() + } + + @Test + fun `instancesByDay covers every date in the grid, empty ones included`() { + val byDay = instancesByDay(weekOf8th, emptyList(), zone) + assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) + assertThat(byDay.values.flatten()).isEmpty() + } +}