Compare commits
5 Commits
ada6976e85
...
81dcbbdce7
| Author | SHA1 | Date | |
|---|---|---|---|
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Choose how the month view is laid out. A new **Month view style** setting
|
||||
(Settings → Views) offers three ways to read a month, each shown with a
|
||||
preview of the layout it produces:
|
||||
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||
- **Continuous** — scroll up and down through the weeks without a break
|
||||
between months. Because the weeks run on unbroken, no month is cut off and
|
||||
no day appears twice, where paging repeats a boundary week at the end of one
|
||||
month and the start of the next. The 1st of each month names itself so you
|
||||
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||
- **Split** — a compact grid showing coloured dots for the days that have
|
||||
something on them, with the day you tap listed in full underneath. Tap the
|
||||
date above the list to open the whole day ([#53]).
|
||||
|
||||
The Agenda view is untouched by this and stays available in all three styles —
|
||||
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||
window with its own range settings.
|
||||
|
||||
### Fixed
|
||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||
it. Previously it was laid out once for the smallest size and simply stretched
|
||||
@@ -1057,5 +1075,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -338,6 +338,7 @@ fun CalendarHost(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 4–6 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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 & anniversaries</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user