Compare commits

...

5 Commits

Author SHA1 Message Date
81dcbbdce7 docs(changelog): month view style (#38, #53)
Filed under Unreleased, matching where the agenda widget fix landed after the
2.16.0 section was cut. Fold into 2.16.0 if that section reopens before it
merges to main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:02:18 +02:00
becb9a6710 feat(month): split style with a day pane (#53)
Adds MonthViewStyle.Split: the month compressed to day numbers and event dots
over a list of the selected day's events.

- The app's first selected-day concept. Every other view drills straight into a
  date; here a tap selects, because the pane below is already the answer to
  "what's on this day" and opening a whole screen would defeat the layout. The
  full Day view stays one tap away on the pane's date header, matching the Week
  and Agenda headers (#37).
- The pane reuses the agenda's row vocabulary (extracted in the groundwork
  commit) rather than growing a parallel set, so the two surfaces read as one
  app. It needs no extra provider query: instancesByDay is already covered by
  the month grid range.
- Selection follows the month — today when the new month holds it, else the 1st
  — so the pane never lists a day the grid isn't showing. Tapping a leading or
  trailing day follows it to its own month.
- Selection and today are different signals (tinted, outlined cell vs. the
  filled circle the other views use for today), so both read when they land on
  the same day.
- The swipe stays on the grid alone; the pane scrolls and is full of tappable
  rows.

MonthScreen gains onEventClick, wired in CalendarHost alongside the other three
views, and MonthUiState.Success now carries its zone for the same reason the
agenda's does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:00:22 +02:00
542744e342 feat(month): continuous vertical scrolling (#38)
Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks
instead of month pages.

The layout unit is the ISO week, not the month. A vertical stack of month grids
would still repeat a boundary week at the end of one month and the start of the
next, which is precisely the duplication the issue asks to be rid of. Streaming
weeks means every date appears exactly once.

- Weeks are addressed by an absolute index (epoch 1900, so indices stay
  non-negative and map 1:1 onto LazyColumn item indices), giving the list one
  stable, gap-free coordinate space to scroll and key by.
- The view model loads a sliding window of weeks around the visible range.
  nextLoadWindow() holds the hysteresis: the window only moves once the visible
  range comes within four weeks of a loaded edge, so scrolling re-queries the
  provider occasionally rather than every frame. Unloaded rows render a
  same-height skeleton, so nothing jumps when the window catches up.
- No dimmed "other month" days — every day in the stream belongs to a month
  equally. Instead the 1st names its month, which is the only marker needed to
  tell one month from the next, and the top bar title tracks the month the
  viewport mostly sits in.
- The horizontal swipe detector and the paged AnimatedContent are both off in
  this style; vertical scrolling owns the gesture. Today and drawer jump-to-date
  animate the list instead of swapping months. The view opens positioned on the
  current month.

MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is
what lets the same row serve a stream that has no enclosing month.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:54:41 +02:00
7510e1f9af feat(settings): add the month view style picker (#38, #53)
Introduces MonthViewStyle{Paged, Continuous, Split} and the setting that
chooses it. Only Paged is wired up so far — the other two land next; this
commit is the pref, the state plumbing and the chooser.

One picker rather than two switches ("vertical scrolling" + "month with
agenda"): independent toggles would multiply into four combinations, most of
which nobody asked for. Split does not disable the Agenda view — that stays a
forward multi-day window, while the split pane lists one selected day.

The chooser lives on the Views settings screen (per-view layout belongs with
the other view configuration) and is a hand-rolled FullScreenPicker rather than
OptionPicker, which cannot render previews: the three options differ in shape,
which a word like "Continuous" does not convey. Each card carries a schematic
drawn from theme tokens, and selection reads three ways over — border weight,
container tint and a check — so it never rests on colour alone. The cards are a
selectableGroup with Role.RadioButton for screen readers.

Folded into the ViewCustomization holder, which had spare arity; the outer
settings combine is still at its five-flow limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:45:42 +02:00
498250650c refactor(month): extract per-week layout and share the agenda's rows
Groundwork for the month view style setting (#38, #53); no behaviour change.

- Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style,
  which streams weeks and has no enclosing YearMonth to slice by, lays rows
  out identically. layoutMonthWeeks (also used by the month widget) keeps its
  signature and becomes a loop over it.
- Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and
  uncapped, so the split style's day pane can list a date without a second
  provider query — the month grid range already covers it.
- Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label
  helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's
  row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its
  text as a parameter now that it serves more than "nothing left today".
- Add the month package's first JVM tests, covering week counts, span
  continuation across row boundaries, lane stacking and instancesByDay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:36:57 +02:00
17 changed files with 1795 additions and 255 deletions

View File

@@ -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

View File

@@ -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<MonthViewStyle> = 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")

View File

@@ -338,6 +338,7 @@ fun CalendarHost(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
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.isSystemInDarkTheme
@@ -9,6 +10,7 @@ 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 +21,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.items
import androidx.compose.foundation.lazy.itemsIndexed
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 +34,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,9 +45,12 @@ 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.key
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
@@ -63,7 +75,11 @@ 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
@@ -80,16 +96,20 @@ 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.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.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
@@ -102,6 +122,7 @@ fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
@@ -112,8 +133,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,17 +152,52 @@ 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 continuous = viewStyle == MonthViewStyle.Continuous
// Today, from whichever state is driving; the clock only covers the first
// frame, before either has loaded.
val today = when {
continuous -> (continuousState as? ContinuousMonthUiState.Success)?.today
else -> (state as? MonthUiState.Success)?.today
} ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Keyed on the week start: week indices are anchored on it, so a changed
// week start would leave the saved scroll offset pointing at a different week.
val listState = key(weekStart) {
rememberLazyListState(
initialFirstVisibleItemIndex =
weekIndexOf(LocalDate(month.year, month.month, 1), weekStart),
)
}
// 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
// The continuous stream has no single "current" month, so the title takes the
// one the viewport mostly sits in: the midweek day of the row below the top
// edge, which flips over as that month's first full week comes into view.
val visibleMonth by remember(weekStart) {
derivedStateOf {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
}
}
val titleMonth = if (continuous) visibleMonth else month
// Feed the visible range back so the sliding data window can follow. The
// view model ignores everything that doesn't near a loaded edge.
LaunchedEffect(listState, continuous) {
if (!continuous) return@LaunchedEffect
snapshotFlow {
val visible = listState.layoutInfo.visibleItemsInfo
(visible.firstOrNull()?.index ?: 0) to (visible.lastOrNull()?.index ?: 0)
}
.distinctUntilChanged()
.collect { (first, last) -> viewModel.onVisibleWeeksChanged(first, last) }
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
// Drives whether the title carries the year.
val currentYear = today.year
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) }
@@ -152,19 +211,33 @@ 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 (continuous) {
scope.launch { listState.animateScrollToItem(weekIndexOf(today, weekStart)) }
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 (continuous) {
scope.launch {
listState.animateScrollToItem(
weekIndexOf(LocalDate(target.year, target.month, 1), weekStart),
)
}
} else {
slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1
viewModel.goToDate(target)
}
}
ModalNavigationDrawer(
@@ -174,7 +247,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,7 +268,7 @@ fun MonthScreen(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MonthTopBar(
month = month,
month = titleMonth,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
@@ -213,11 +286,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 +302,38 @@ 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 (continuous) {
ContinuousMonthContent(
state = continuousState,
listState = listState,
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
} else if (viewStyle == MonthViewStyle.Split) {
SplitMonthContent(
state = state,
selected = selectedDate,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onSelectDay = viewModel::selectDate,
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 +350,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,6 +380,32 @@ private fun MonthContent(
}
}
/**
* Continuous style content. 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,
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
) {
when (state) {
ContinuousMonthUiState.Loading -> MonthGridLoading()
is ContinuousMonthUiState.Failure ->
CalendarFailure(reason = state.reason, onRetry = onRetry)
is ContinuousMonthUiState.Success -> ContinuousMonthGrid(
state = state,
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MonthTopBar(
@@ -391,6 +494,14 @@ private val CELL_GAP = 2.dp
private val CELL_SHAPE = RoundedCornerShape(12.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
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
@@ -406,11 +517,12 @@ 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,
modifier = Modifier
@@ -421,6 +533,346 @@ private fun MonthGrid(
}
}
/**
* The continuous grid (#38): one uninterrupted vertical stream of weeks.
*
* The list is 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.
*
* Deliberately not a stack of month grids — that would still repeat a boundary
* week at the end of one month and the start of the next, which is exactly the
* duplication #38 asks to be rid of.
*/
@Composable
private fun ContinuousMonthGrid(
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),
)
}
}
}
}
/**
* The month-changing horizontal swipe, shared by the paged and split styles.
* Accumulates the drag and commits past a small threshold on release — the grid
* doesn't follow the finger, so there is no distance to rubber-band against.
*/
@Composable
private fun rememberMonthSwipeModifier(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
return Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
}
/**
* Split style content: the compact grid keeps the month swipe, the pane below it
* lists whatever day is selected.
*
* No [AnimatedContent] here, unlike the paged style. The grid is 46 rows tall
* depending on the month, so sliding one month over another would animate a
* height change under a pane that is trying to hold still.
*/
@Composable
private fun SplitMonthContent(
state: MonthUiState,
selected: LocalDate,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onSelectDay: (LocalDate) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
) {
when (state) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
SplitMonthGrid(
state = state,
selected = selected,
showWeekNumbers = showWeekNumbers,
onSelectDay = onSelectDay,
// The swipe lives on the grid alone — the pane scrolls and is
// full of tappable rows, so it shouldn't also be a month gesture.
modifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev),
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
SplitDayPane(
date = selected,
today = state.today,
events = state.instancesByDay[selected].orEmpty(),
zone = state.zone,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
modifier = Modifier.weight(1f).fillMaxWidth(),
)
}
}
}
// --- 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
private const val SPLIT_MAX_DOTS = 3
/**
* 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
private 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.forEach { day ->
SplitDayCell(
date = day,
events = state.instancesByDay[day].orEmpty(),
isToday = day == state.today,
isSelected = day == selected,
inMonth = day.month == month.month && day.year == month.year,
dark = dark,
onClick = { onSelectDay(day) },
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
}
}
}
/**
* One compact day: its number over up to [SPLIT_MAX_DOTS] 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<EventInstance>,
isToday: Boolean,
isSelected: Boolean,
inMonth: Boolean,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val background = when {
isSelected -> MaterialTheme.colorScheme.primaryContainer
inMonth -> MaterialTheme.colorScheme.surfaceContainer
else -> MaterialTheme.colorScheme.surfaceContainerLow
}
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.background(background)
.then(
if (isSelected) {
Modifier.border(1.5.dp, MaterialTheme.colorScheme.primary, CELL_SHAPE)
} else {
Modifier
},
)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DayNumberCell(
date = date,
isToday = isToday,
inMonth = inMonth,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(2.dp))
SplitDots(events = events, dark = dark)
}
}
}
/** Up to three colour dots for a day, plus a count when more are hidden. */
@Composable
private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
if (events.isEmpty()) return
val soften = LocalSoftenColors.current
val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) }
val extra = events.size - colors.size
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
colors.forEach { argb ->
Box(
modifier = Modifier
.size(SPLIT_DOT_SIZE)
.background(eventFill(argb, dark, soften), CircleShape),
)
}
if (extra > 0) {
Text(
text = "+$extra",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* 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
private fun SplitDayPane(
date: LocalDate,
today: LocalDate,
events: List<EventInstance>,
zone: TimeZone,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
val dimCutoff = LocalDimCutoff.current
Column(modifier = modifier) {
AgendaDayHeader(date = date, today = today, onOpenDay = onOpenDay)
if (events.isEmpty()) {
AgendaEmptyDayRow(
text = stringResource(R.string.month_split_no_events),
onClick = { onCreateEvent(date) },
)
} else {
LazyColumn(
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp),
) {
itemsIndexed(
items = events,
key = { _, event -> event.instanceId },
) { index, event ->
AgendaEventRow(
event = event,
day = date,
zone = zone,
position = positionOf(index, events.size),
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
onClick = { onEventClick(event) },
)
}
}
}
}
}
/** A week the sliding window hasn't loaded yet — same height, so nothing jumps. */
@Composable
private fun ContinuousWeekPlaceholder() {
Row(
modifier = Modifier
.fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.surfaceContainerLow, CELL_SHAPE),
)
}
}
}
/**
* 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,10 +884,11 @@ private fun MonthGrid(
private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
month: YearMonth,
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
labelMonthOnFirst: Boolean = false,
) {
val dark = isSystemInDarkTheme()
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
@@ -466,14 +919,13 @@ 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)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
color = if (inMonth(d)) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
@@ -487,7 +939,15 @@ private fun MonthWeekRow(
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
inMonth = inMonth(d),
// Continuous has no month boundaries to dim across, so
// the 1st names its month instead — the only marker
// telling one month from the next inside the stream.
monthLabel = if (labelMonthOnFirst && d.day == 1) {
shortMonthName(d)
} else {
null
},
modifier = Modifier.weight(1f),
)
}
@@ -620,6 +1080,7 @@ private fun DayNumberCell(
isToday: Boolean,
inMonth: Boolean,
modifier: Modifier = Modifier,
monthLabel: String? = null,
) {
Box(
modifier = modifier.height(DAY_NUMBER_HEIGHT),
@@ -639,6 +1100,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 +1121,16 @@ private fun DayNumberCell(
}
}
/** Locale-short month name ("Jul", "Juli"), for the 1st in the continuous 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(

View File

@@ -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,44 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/**
* State for the continuous style (#38). Weeks are keyed by absolute week index
* rather than gathered into months: the whole point of the style is that there
* are no month boundaries to scroll across, so there is no "current month" here
* and no notion of an out-of-month day. [weeksByIndex] holds 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 weeksByIndex: Map<Int, MonthWeek>,
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<MonthWeek>,
val instancesByDay: Map<LocalDate, List<EventInstance>> = 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
}

View File

@@ -29,6 +29,7 @@ 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 +67,14 @@ class MonthViewModel @Inject constructor(
initialValue = false,
)
/** How the grid is laid out and navigated (#38, #53). */
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthViewStyle.Paged,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
@@ -91,21 +100,121 @@ class MonthViewModel @Inject constructor(
initialValue = MonthUiState.Loading,
)
// --- Continuous style (#38) -------------------------------------------
//
// The continuous grid is one endless stream of weeks, so it can't load "a
// month" — it loads a sliding window of week indices around whatever is on
// screen. The window only moves when the visible range comes within
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally
// rather than on every frame.
// Seeded around today so the first frame has data. The week-start preference
// hasn't arrived yet, but anchoring on a different day shifts an index by at
// most one week — well inside the pad, and the first scroll report corrects it.
private val _loadedWeeks = MutableStateFlow(
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
)
val continuousState: StateFlow<ContinuousMonthUiState> =
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
.flatMapLatest { (window, ws) ->
val first = weekStartForIndex(window.first, ws)
val last = weekStartForIndex(window.last, 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, calendars, instances)
}
}
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = ContinuousMonthUiState.Loading,
)
/**
* Report which absolute week 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 onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) {
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it }
}
private fun buildContinuousState(
window: IntRange,
weekStart: DayOfWeek,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): ContinuousMonthUiState {
if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val weeks = window.associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, instances, zone)
}
return ContinuousMonthUiState.Success(
today = todayDate,
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<LocalDate> = _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
}
/** 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 +226,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,
)
}
}
@@ -149,31 +261,63 @@ internal fun layoutMonthWeeks(
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, instances, zone)
}
}
/**
* 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<LocalDate>,
instances: List<EventInstance>,
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<LocalDate>,
instances: List<EventInstance>,
zone: TimeZone,
): Map<LocalDate, List<EventInstance>> =
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(compareByDescending<EventInstance> { 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 +335,64 @@ internal fun monthGridRange(
return start..end
}
/**
* How many weeks the continuous window loads beyond the visible range, and how
* close the visible range may drift to a loaded edge before it reloads. The pad
* is generous relative to the trigger so a steady scroll crosses the trigger
* well before it would run out of laid-out weeks.
*/
private const val WINDOW_PAD = 12
private const val WINDOW_EDGE = 4
/**
* 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): IntRange? {
val comfortablyInside =
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
if (comfortablyInside) return null
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
}
/**
* The continuous grid addresses weeks by an absolute index rather than a
* (month, row) pair, so the list has one stable, gap-free coordinate space to
* scroll through and key its items by. Index 0 is the first week of 1900 under
* the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT].
*
* The epoch is deliberately far in the past so every index is non-negative,
* which keeps the LazyColumn's item indices and week indices the same number.
*/
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 continuous grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
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

View File

@@ -0,0 +1,45 @@
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,
/**
* One uninterrupted vertical stream of weeks. Each date appears exactly once,
* which is the point — paging repeats a boundary week at both ends (#38).
*/
Continuous,
/** Compact dots-only grid over a list of the selected day's events (#53). */
Split,
}
@get:StringRes
val MonthViewStyle.labelRes: Int
get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged
MonthViewStyle.Continuous -> R.string.month_style_continuous
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.Split -> R.string.month_style_split_summary
}

View File

@@ -0,0 +1,297 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
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
/**
* The Month view style chooser (#38, #53). Each option carries a schematic of the
* layout it produces rather than a bare label — the three differ in *shape*, which
* a word like "Continuous" doesn't convey on its own.
*
* Tapping applies immediately and the picker stays open, matching the App name
* picker: the change is worth seeing land before leaving.
*/
@Composable
internal fun MonthViewStylePicker(
selected: MonthViewStyle,
onSelect: (MonthViewStyle) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.settings_month_view_style),
onDismiss = onDismiss,
predictiveBack = true,
) {
Text(
text = stringResource(R.string.settings_month_view_style_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MonthViewStyle.entries.forEach { style ->
MonthStyleOptionCard(
style = style,
selected = style == selected,
onClick = { onSelect(style) },
)
}
}
}
}
/**
* One selectable style: its schematic, name and a line on what it does. Selection
* reads three ways over — border weight, container tint and a check — so it never
* rests on colour alone.
*/
@Composable
private fun MonthStyleOptionCard(
style: MonthViewStyle,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(24.dp)
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(containerColor)
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(all = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
MonthStyleSchematic(style)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(style.labelRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(style.descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Selection indicator: a filled check when active, an empty ring otherwise.
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
},
),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp),
)
}
}
}
}
private val SCHEMATIC_WIDTH = 64.dp
private val SCHEMATIC_HEIGHT = 60.dp
private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp)
/**
* A miniature of the layout, drawn from plain boxes on theme tokens — small
* enough to read as an icon, literal enough that the three are told apart at a
* glance: pages sit side by side, the continuous stream runs off both edges, the
* split style stacks a dotted grid over a list.
*/
@Composable
private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT)
.clip(SCHEMATIC_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.clipToBounds(),
contentAlignment = Alignment.Center,
) {
when (style) {
MonthViewStyle.Paged -> PagedSchematic()
MonthViewStyle.Continuous -> ContinuousSchematic()
MonthViewStyle.Split -> SplitSchematic()
}
}
}
/** Two pages side by side, the second peeking in — a swipe away. */
@Composable
private fun PagedSchematic() {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SchematicGrid(rows = 4, modifier = Modifier.weight(1f))
// The next page, cropped by the container — the swipe affordance.
SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true)
}
}
/** One column of weeks running off both edges: no page breaks, no repeated days. */
@Composable
private fun ContinuousSchematic() {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Six rows in a 60dp box overflow deliberately: the stream is clipped top
// and bottom rather than fitting neatly, which is the whole idea.
repeat(6) { index ->
SchematicWeekRow(dim = index == 0 || index == 5)
}
}
}
/** A squished dotted grid over the selected day's list. */
@Composable
private fun SplitSchematic() {
Column(
modifier = Modifier.fillMaxWidth().padding(6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
SchematicGrid(rows = 3, dotted = true)
// The day pane: two stand-in event rows.
repeat(2) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)),
)
}
}
}
/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */
@Composable
private fun SchematicGrid(
rows: Int,
modifier: Modifier = Modifier,
dim: Boolean = false,
dotted: Boolean = false,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp),
) {
repeat(rows) {
if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim)
}
}
}
/** A week as seven filled cells. */
@Composable
private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = if (dim) 0.18f else 0.38f)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(5.dp)
.clip(RoundedCornerShape(1.5.dp))
.background(color),
)
}
}
}
/** A week as seven event dots — the split style's compact grid. */
@Composable
private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = if (dim) 0.18f else 0.38f)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(CircleShape)
.background(color),
)
}
}
}

View File

@@ -125,6 +125,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 +848,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 +911,14 @@ private fun ViewsScreen(
)
}
}
if (showMonthStyle) {
MonthViewStylePicker(
selected = state.monthViewStyle,
onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false },
)
}
}
/** One reorderable view row: the view's icon and name, an optional [trailing]

View File

@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */

View File

@@ -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<CalendarView>,
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<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}

View File

@@ -372,6 +372,17 @@
<item quantity="other">%d days</item>
</plurals>
<string name="settings_section_views">Views</string>
<!-- Month view style (#38, #53) -->
<string name="settings_month_header">Month view</string>
<string name="settings_month_view_style">Month view style</string>
<string name="settings_month_view_style_summary">Choose how the month view is laid out and how you move through it.</string>
<string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string>
<string name="month_style_continuous">Continuous</string>
<string name="month_style_continuous_summary">Scroll up and down through the weeks. No month is cut off and no day appears twice.</string>
<string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
@@ -411,7 +422,7 @@
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>

View File

@@ -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)

View File

@@ -0,0 +1,121 @@
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.YearMonth
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
* The continuous grid's coordinate space. Every row's identity — and the
* LazyColumn item it maps to — hangs off this arithmetic, so it gets its own
* tests rather than being exercised only through the UI.
*/
class ContinuousWeekIndexTest {
// 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week.
private val mon = LocalDate(2026, 6, 8)
private val wed = LocalDate(2026, 6, 10)
@Test
fun `every day of a week shares one index`() {
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(indices.toSet()).hasSize(1)
}
@Test
fun `consecutive weeks are consecutive indices`() {
val a = weekIndexOf(mon, DayOfWeek.MONDAY)
val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
assertThat(b).isEqualTo(a + 1)
assertThat(back).isEqualTo(a - 1)
}
@Test
fun `index round-trips back to the week's first day`() {
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
}
}
@Test
fun `the week start shifts which week a boundary day belongs to`() {
// Sunday the 14th closes the Monday-anchored week but opens the Sunday one.
val sun = LocalDate(2026, 6, 14)
assertThat(weekIndexOf(sun, DayOfWeek.MONDAY))
.isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY))
assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY))
.isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY))
}
@Test
fun `indices are non-negative across the supported span`() {
// The epoch sits before any date the grid scrolls to, so item indices and
// week indices stay the same number — no offset to reconcile.
DayOfWeek.entries.forEach { ws ->
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0)
}
}
@Test
fun `the list spans 1900 through 2100`() {
DayOfWeek.entries.forEach { ws ->
val count = continuousWeekCount(ws)
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count)
// ~200 years of weeks, give or take the anchor.
assertThat(count).isIn(10_400..10_500)
}
}
@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 four weeks of the top edge → reload, padded on both sides.
val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7)
assertThat(widened).isEqualTo(-10..19)
val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99)
assertThat(atBottom).isEqualTo(82..111)
}
@Test
fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505))
.isEqualTo(488..517)
}
@Test
fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query.
var loaded = 0..100
val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!!
loaded = window
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), 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, kotlinx.datetime.Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}

View File

@@ -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()
}
}