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]
|
## [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
|
### Fixed
|
||||||
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
- 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
|
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
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
[#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
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
[#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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -256,6 +257,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
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
|
* 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 "+"
|
* 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 PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
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 TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ fun CalendarHost(
|
|||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
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
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
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.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
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.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
// 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
|
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||||
@@ -370,7 +350,10 @@ private fun AgendaList(
|
|||||||
if (day.events.isEmpty()) {
|
if (day.events.isEmpty()) {
|
||||||
// An anchored, event-less today (#35) — "nothing left today".
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
item(key = "placeholder-${day.date}") {
|
item(key = "placeholder-${day.date}") {
|
||||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(
|
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
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -559,64 +448,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag distance that turns a calendar page, shared by the month, week and day
|
||||||
|
* views so all three answer a swipe at the same point.
|
||||||
|
*
|
||||||
|
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
||||||
|
* changed the month, and a page that turns on an unintended gesture reads as the
|
||||||
|
* animation misfiring rather than as the gesture being over-eager.
|
||||||
|
*/
|
||||||
|
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
||||||
|
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
||||||
|
*
|
||||||
|
* Waiting for the lift meant the page sat still under a finger that had already
|
||||||
|
* travelled far enough to ask for it, and the answer only arrived once you let
|
||||||
|
* go — which reads as the view being slow rather than as a deliberate commit.
|
||||||
|
* Firing on the threshold is what makes the gesture feel like it is being
|
||||||
|
* followed. The trade is that a drag can no longer be taken back by dragging the
|
||||||
|
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
||||||
|
* the page you land on is one swipe back.
|
||||||
|
*
|
||||||
|
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
||||||
|
* underneath this, and a two-dimensional detector here would claim those drags
|
||||||
|
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
||||||
|
* detector's slop while a vertical one is consumed below, and the two coexist.
|
||||||
|
* (The month view's split style needs a vertical axis as well, so it keeps its
|
||||||
|
* own axis-locking detector rather than using this.)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSwipe(
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
||||||
|
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
||||||
|
var accum = 0f
|
||||||
|
// One page per gesture: without this a long drag would keep re-firing
|
||||||
|
// every time the accumulator crossed the threshold again.
|
||||||
|
var fired = false
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { _, drag ->
|
||||||
|
accum += drag
|
||||||
|
if (!fired) {
|
||||||
|
val commit = when {
|
||||||
|
accum < -threshold -> onSwipeNext
|
||||||
|
accum > threshold -> onSwipePrev
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (commit != null) {
|
||||||
|
fired = true
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* springy settle, rather than a fixed easing curve.
|
* fixed easing curve.
|
||||||
|
*
|
||||||
|
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
|
||||||
|
* movement, and driving a whole calendar page with it made the settle read as a
|
||||||
|
* twitch instead of a glide.
|
||||||
*
|
*
|
||||||
* Read it in a composable scope (this helper) so it can be captured by the
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
* for use in non-composable transition lambdas, and reused on its own as the
|
||||||
|
* reduced-motion fallback.
|
||||||
|
*
|
||||||
|
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
|
||||||
|
* eases *opacity*, and a fade that bounced with the slide would shimmer.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||||
|
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||||
|
* other, position on a spring and opacity on an easing curve.
|
||||||
*
|
*
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||||
|
* transition under reduced motion; typically
|
||||||
* [rememberCalendarFadeSpec].
|
* [rememberCalendarFadeSpec].
|
||||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
|
|||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
}
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return ContentTransform(
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
targetContentEnter =
|
||||||
|
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||||
|
initialContentExit =
|
||||||
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
|
// AnimatedContent clips to the animating container by default, which
|
||||||
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
|
// size change here to contain — both pages are the same grid.
|
||||||
|
sizeTransform = SizeTransform(clip = false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a page travels, as a fraction of the container width.
|
||||||
|
*
|
||||||
|
* A full width was the obvious reading of "paging", but the two pages are
|
||||||
|
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||||
|
* another — the movement carried the whole transition and had a long way to go.
|
||||||
|
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||||
|
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||||
|
* leaves nothing skating past.
|
||||||
|
*/
|
||||||
|
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ private fun CustomReminderEditor(
|
|||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -266,8 +265,6 @@ private fun DayContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -296,20 +293,7 @@ private fun DayContent(
|
|||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's compact grid *become* the full month rather than be
|
||||||
|
* swapped for it (#53).
|
||||||
|
*
|
||||||
|
* The two grids are separate composables — the compact one draws dots, the
|
||||||
|
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||||
|
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||||
|
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||||
|
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||||
|
* to the bar it stood for.
|
||||||
|
*
|
||||||
|
* It travels as a composition local rather than as parameters because the pieces
|
||||||
|
* sit five levels below where the scopes exist, and because a null default is
|
||||||
|
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||||
|
* styles share the same row and cell composables, provide nothing, and pay
|
||||||
|
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||||
|
* cross-fade underneath.
|
||||||
|
*/
|
||||||
|
internal sealed interface MonthMorphKey {
|
||||||
|
/** A day's background pill — the structural anchor the rest rides on. */
|
||||||
|
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event *on one day*. Keyed by date as well as instance because a
|
||||||
|
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||||
|
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||||
|
* and the rest are left unmatched on purpose. They fade where they stand
|
||||||
|
* while the bar sweeps out over them, which is the honest reading — one of
|
||||||
|
* them could not become the bar without the others teleporting into it.
|
||||||
|
*/
|
||||||
|
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's "+N" marker. The compact grid writes it as a count beside the dots
|
||||||
|
* and the expanded one as a row of small dots under the bars, but they stand
|
||||||
|
* for the same events and appear on exactly the same days — both sides seat
|
||||||
|
* three and overflow the rest — so they are a matched pair, not two unrelated
|
||||||
|
* bits of text. Tagged, the marker travels with its day like everything else
|
||||||
|
* in the cell.
|
||||||
|
*/
|
||||||
|
data class Overflow(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected day's outline. The expanded grid draws no outline — once the
|
||||||
|
* cells carry real event bars it is one mark too many — so this pairs the
|
||||||
|
* compact grid's outline with an *invisible* stand-in over the same cell
|
||||||
|
* there. Without one it had nowhere to travel and could only fade in place
|
||||||
|
* while everything around it moved.
|
||||||
|
*/
|
||||||
|
data class Selection(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
|
data object Handle : MonthMorphKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal class MonthMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the grid is mid-morph, for content that has to stop clipping to let
|
||||||
|
* the travelling pieces through.
|
||||||
|
*
|
||||||
|
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
|
||||||
|
* of the way down the compact grid, its bar most of the way down the expanded
|
||||||
|
* one. Clipped to the row it is arriving at, a mark spends the first half of its
|
||||||
|
* journey outside those bounds and is simply not drawn — so it appears from
|
||||||
|
* nowhere, part-way through, already near its destination. Rendering in place
|
||||||
|
* rather than in an overlay is what subjects them to that clip, and is worth
|
||||||
|
* keeping; the clip is what has to yield, and only while pieces are in flight.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun morphInFlight(): Boolean {
|
||||||
|
val morph = LocalMonthMorph.current ?: return false
|
||||||
|
return morph.shared.isTransitionActive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||||
|
* number — so it animates between its two positions and sizes.
|
||||||
|
*
|
||||||
|
* ### Why nothing renders in the overlay
|
||||||
|
*
|
||||||
|
* By default a travelling piece is painted into an overlay above the *entire*
|
||||||
|
* regular tree, so it can fly over anything in its way. That is right for a
|
||||||
|
* thumbnail crossing a screen, and wrong here: everything is moving inside one
|
||||||
|
* grid, and the overlay meant every untagged neighbour — the selection outline,
|
||||||
|
* the "+N" markers — spent the transition buried under pieces that had left the
|
||||||
|
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
|
||||||
|
* bought a worse problem: they then floated over the grid on their own layer,
|
||||||
|
* out of step with it.
|
||||||
|
*
|
||||||
|
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||||
|
* what makes the grid read as a single surface changing shape rather than a
|
||||||
|
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||||
|
* ancestors: the cells, marks and markers all travel within the grid.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphElement.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||||
|
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||||
|
* cross-fade inside them.
|
||||||
|
*
|
||||||
|
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||||
|
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||||
|
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||||
|
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
|
* chooser.
|
||||||
|
*
|
||||||
|
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||||
|
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||||
|
* event bar's colour and every preview follows automatically. The trick is
|
||||||
|
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||||
|
* out at a full phone width and a plausible viewport height, and a
|
||||||
|
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||||
|
*
|
||||||
|
* The month, today's position and the week start are all real; only the events
|
||||||
|
* are stand-ins, since the settings screen has no business querying the
|
||||||
|
* provider for a thumbnail.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthStylePreview(
|
||||||
|
style: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// grid's own clickables see them, and give screen readers one label
|
||||||
|
// instead of six weeks of day cells.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
// Measure the grid at a full phone viewport but report the scaled
|
||||||
|
// size, so the node occupies exactly what it draws.
|
||||||
|
//
|
||||||
|
// Modifier.requiredSize would be the obvious way to force the larger
|
||||||
|
// measurement, but it *centres* content that overflows the incoming
|
||||||
|
// constraints — which pushed the grid to a negative offset and left
|
||||||
|
// only its bottom-right corner inside the clip.
|
||||||
|
.layout { measurable, _ ->
|
||||||
|
val fullWidth = screenWidth.roundToPx()
|
||||||
|
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||||
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
|
placeable.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
|
when (style) {
|
||||||
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
|
monthIndexOf(YearMonth(today.year, today.month)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
// Start a week above today's, so the preview shows a
|
||||||
|
// stream running past the viewport rather than one
|
||||||
|
// beginning at its top edge.
|
||||||
|
initialFirstVisibleItemIndex =
|
||||||
|
weekIndexOf(today, weekStart) - 1,
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Split -> {
|
||||||
|
SplitMonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
selected = today,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onSelectDay = {},
|
||||||
|
)
|
||||||
|
SplitDayPane(
|
||||||
|
date = today,
|
||||||
|
today = today,
|
||||||
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
|
zone = zone,
|
||||||
|
onOpenDay = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onCreateEvent = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport the preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||||
|
* three styles comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
private val VIRTUAL_HEIGHT = 440.dp
|
||||||
|
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
|
private class SampleMonth(
|
||||||
|
val month: MonthUiState.Success,
|
||||||
|
val continuous: ContinuousMonthUiState.Success,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month's worth of stand-in events, laid out through the same
|
||||||
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
|
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a token here would misrepresent what the grid actually renders.
|
||||||
|
*/
|
||||||
|
private fun sampleMonthState(
|
||||||
|
today: LocalDate,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): SampleMonth {
|
||||||
|
val ym = YearMonth(today.year, today.month)
|
||||||
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
|
val events = sampleEvents(first, today, zone)
|
||||||
|
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
|
val month = MonthUiState.Success(
|
||||||
|
month = ym,
|
||||||
|
today = today,
|
||||||
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The month either side of this one: enough for Continuous to show a block,
|
||||||
|
// the whitespace under it and the next month's header coming up, and for
|
||||||
|
// Dense to have somewhere to scroll in both directions.
|
||||||
|
val centre = monthIndexOf(ym)
|
||||||
|
val window = (centre - 1)..(centre + 1)
|
||||||
|
val continuous = ContinuousMonthUiState.Success(
|
||||||
|
today = today,
|
||||||
|
monthsByIndex = window.associateWith { index ->
|
||||||
|
val m = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
|
||||||
|
},
|
||||||
|
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, events, zone)
|
||||||
|
},
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
return SampleMonth(month, continuous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
var id = 0L
|
||||||
|
fun next() = ++id
|
||||||
|
|
||||||
|
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = day.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spread across the month so most weeks carry something, with one multi-day
|
||||||
|
// bar to show a span bridging cells and a busy today for the split pane.
|
||||||
|
return buildList {
|
||||||
|
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||||
|
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||||
|
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(timed(today, 9, 1, 0))
|
||||||
|
add(timed(today, 12, 1, 1))
|
||||||
|
add(timed(today, 15, 2, 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,12 +39,85 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
val countByDay: Map<LocalDate, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||||
|
* order, capped at [laneCap] lanes.
|
||||||
|
*
|
||||||
|
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||||
|
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||||
|
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||||
|
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||||
|
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||||
|
*
|
||||||
|
* Deriving the dots independently — by distinct colour, as they first were —
|
||||||
|
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||||
|
* that shared a calendar into a single dot.
|
||||||
|
*/
|
||||||
|
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||||
|
byLane[span.lane] = span.event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||||
|
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||||
|
byLane[free[i]] = event
|
||||||
|
}
|
||||||
|
return byLane.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
* absolute month index and carries only its own days — the boundary week keeps
|
||||||
|
* its seven columns so the grid geometry never shifts, but the neighbour month's
|
||||||
|
* dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
|
||||||
|
*
|
||||||
|
* The same loaded window is served two ways, because the Dense style is the same
|
||||||
|
* scroll with the seams taken out and it would be wasteful to query the provider
|
||||||
|
* twice for one range:
|
||||||
|
*
|
||||||
|
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
|
||||||
|
* clipped to its own month.
|
||||||
|
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
|
||||||
|
* weeks left carrying both months.
|
||||||
|
*
|
||||||
|
* Both hold only the loaded window; indices outside it render as placeholders
|
||||||
|
* until the window catches up.
|
||||||
|
*/
|
||||||
|
sealed interface ContinuousMonthUiState {
|
||||||
|
data object Loading : ContinuousMonthUiState
|
||||||
|
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||||
|
data class Success(
|
||||||
|
val today: LocalDate,
|
||||||
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
) : ContinuousMonthUiState
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface MonthUiState {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : 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(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
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
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
@@ -66,6 +70,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
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
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -73,8 +85,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws) ->
|
.flatMapLatest { (ym, ws, style) ->
|
||||||
|
// The mirror of the gate below: the paged grid is what Paged and
|
||||||
|
// Split draw, so under a scrolling style this query is a month
|
||||||
|
// of provider work for a view that isn't on screen.
|
||||||
|
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||||
val range = monthGridRange(ym, ws, zone)
|
val range = monthGridRange(ym, ws, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
@@ -91,21 +107,202 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
|
//
|
||||||
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
|
// they load a sliding window of month indices around whatever is on screen.
|
||||||
|
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||||
|
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||||
|
// on every frame.
|
||||||
|
//
|
||||||
|
// The window also *starts small and grows*. A provider Instances query
|
||||||
|
// expands recurrences across its whole range, so opening straight onto a
|
||||||
|
// year of months made the first frame wait for eleven months of expansion
|
||||||
|
// when only one was about to be looked at. It now opens on INITIAL_PAD
|
||||||
|
// months, and each completed load widens by GROWTH_STEP in both directions
|
||||||
|
// until MAX_PAD — the months you can reach by scrolling arrive while you're
|
||||||
|
// still looking at the first one.
|
||||||
|
|
||||||
|
/** How far around the visible range we currently load; grows as loads land. */
|
||||||
|
@Volatile
|
||||||
|
private var loadPad = INITIAL_PAD
|
||||||
|
|
||||||
|
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||||
|
@Volatile
|
||||||
|
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { it..it }
|
||||||
|
|
||||||
|
private val _loadedMonths = MutableStateFlow(
|
||||||
|
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||||
|
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||||
|
Triple(window, ws, style)
|
||||||
|
}
|
||||||
|
.flatMapLatest { (window, ws, style) ->
|
||||||
|
// Nothing to load for the paged and split styles — they have
|
||||||
|
// their own single-month flow, and querying a year of months
|
||||||
|
// behind them is pure waste.
|
||||||
|
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
|
||||||
|
// Widened to whole grid weeks at both ends: a month block still
|
||||||
|
// has to know about an event that starts in the boundary week's
|
||||||
|
// clipped-off days, or a bar running into the block would vanish.
|
||||||
|
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
||||||
|
val last = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
.startOfGridWeek(ws)
|
||||||
|
.plus(6, DateTimeUnit.DAY)
|
||||||
|
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildContinuousState(window, ws, style, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A load landing is the cue to reach further out. Widening from here
|
||||||
|
// rather than on a timer means each step waits for the previous one,
|
||||||
|
// so the ladder can never outrun the provider.
|
||||||
|
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||||
|
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = ContinuousMonthUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report which absolute month indices are on screen. Cheap to call on every
|
||||||
|
* scroll frame: it returns immediately unless the visible range has drifted
|
||||||
|
* close enough to a loaded edge to warrant a wider query.
|
||||||
|
*/
|
||||||
|
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||||
|
visibleMonths = firstIndex..lastIndex
|
||||||
|
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
|
||||||
|
?.let { _loadedMonths.value = clampMonthWindow(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
|
||||||
|
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
|
||||||
|
* that re-emit the same window don't restart it.
|
||||||
|
*/
|
||||||
|
private fun widenLoadedWindow() {
|
||||||
|
if (loadPad >= MAX_PAD) return
|
||||||
|
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
|
||||||
|
_loadedMonths.value = clampMonthWindow(
|
||||||
|
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContinuousState(
|
||||||
|
window: IntRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
style: MonthViewStyle,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): ContinuousMonthUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
|
// events on its seven days instead of re-scanning a year of them.
|
||||||
|
val byDay = DayIndex(instances, zone)
|
||||||
|
// Only the style on screen is laid out. Building both doubled the work
|
||||||
|
// for a layout nothing was going to draw.
|
||||||
|
val months = if (style == MonthViewStyle.Continuous) {
|
||||||
|
window.associateWith { index ->
|
||||||
|
val ym = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val weeks = if (style == MonthViewStyle.Dense) {
|
||||||
|
weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
return ContinuousMonthUiState.Success(
|
||||||
|
today = todayDate,
|
||||||
|
monthsByIndex = months,
|
||||||
|
weeksByIndex = weeks,
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Split style (#53) ------------------------------------------------
|
||||||
|
//
|
||||||
|
// The first selected-day concept in the app: the other views drill straight
|
||||||
|
// into a date, while the split style keeps one selected and lists it below
|
||||||
|
// the grid.
|
||||||
|
|
||||||
|
private val _selectedDate = MutableStateFlow(todayDate)
|
||||||
|
val selectedDate: StateFlow<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() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
|
_selectedDate.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track [_month] to the month a scrolling style is showing. Those styles
|
||||||
|
* navigate by scroll position and never set [_month] themselves, so it would
|
||||||
|
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||||
|
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||||
|
* scrolling style's list, would jump there instead of holding position. The
|
||||||
|
* selection is realigned alongside it so that landing on the Split style
|
||||||
|
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||||
|
*/
|
||||||
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
|
if (_month.value == ym) return
|
||||||
|
_month.value = ym
|
||||||
|
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -117,10 +314,13 @@ class MonthViewModel @Inject constructor(
|
|||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,41 +339,124 @@ internal fun layoutMonthWeeks(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but reusing a [DayIndex] already built for a wider range — what the
|
||||||
|
* scrolling styles need, since they lay out many months from one query and
|
||||||
|
* rebuilding the index per month would put the cost straight back.
|
||||||
|
*/
|
||||||
|
internal fun layoutMonthWeeks(
|
||||||
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
byDay: DayIndex,
|
||||||
|
zone: TimeZone,
|
||||||
): List<MonthWeek> {
|
): List<MonthWeek> {
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
val daysInMonth =
|
|
||||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
|
||||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
layoutCalendarWeek(days, byDay.eventsOn(days), 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) } },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events bucketed by the dates they cover, built once per load.
|
||||||
|
*
|
||||||
|
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
|
||||||
|
* the ones touching its seven days. That is fine for a single month's grid, but
|
||||||
|
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
|
||||||
|
* *every* instance in an eleven-month window for each of them made the work grow
|
||||||
|
* with the number of enabled calendars until opening the view stalled.
|
||||||
|
*
|
||||||
|
* Membership is decided by [coversDay] itself rather than by re-deriving the
|
||||||
|
* rule from the timestamps: the all-day and timed cases have enough edge cases
|
||||||
|
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
|
||||||
|
* that a second implementation would drift. The walk only visits an event's own
|
||||||
|
* candidate days, so the check runs a couple of times per event rather than once
|
||||||
|
* per event per row.
|
||||||
|
*/
|
||||||
|
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
|
||||||
|
|
||||||
|
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
|
||||||
|
instances.forEach { event ->
|
||||||
|
// All-day events are anchored to UTC midnights; timed ones to the
|
||||||
|
// device zone. Either way the candidate span is the event's own
|
||||||
|
// dates, which is one or two days for almost everything.
|
||||||
|
val anchor = if (event.isAllDay) TimeZone.UTC else zone
|
||||||
|
var day = event.start.toLocalDateTime(anchor).date
|
||||||
|
val lastCandidate = event.end.toLocalDateTime(anchor).date
|
||||||
|
while (day <= lastCandidate) {
|
||||||
|
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every event touching any of [days], each listed once, in input order. */
|
||||||
|
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
|
||||||
|
if (days.isEmpty()) return emptyList()
|
||||||
|
val hits = days.flatMap { byDay[it].orEmpty() }
|
||||||
|
// A multi-day event is bucketed on each date it covers, so a row that
|
||||||
|
// holds several of its days would otherwise list it several times.
|
||||||
|
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||||
|
* the continuous style — which streams weeks rather than months and so has no
|
||||||
|
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||||
|
*
|
||||||
|
* [days] must be the row's seven consecutive dates, in display order.
|
||||||
|
*/
|
||||||
|
internal fun layoutCalendarWeek(
|
||||||
|
days: List<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
|
* 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.
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
@@ -191,6 +474,198 @@ internal fun monthGridRange(
|
|||||||
return start..end
|
return start..end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sliding window's shape.
|
||||||
|
*
|
||||||
|
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||||
|
* visible one, so opening the view costs about what the paged style costs. Each
|
||||||
|
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
|
||||||
|
* in the months a scroll could reach while the first ones are already on screen.
|
||||||
|
*
|
||||||
|
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
|
||||||
|
* it reloads. It is always kept below the current pad — a trigger at or beyond
|
||||||
|
* the pad would re-fire the moment its own reload landed.
|
||||||
|
*/
|
||||||
|
private const val INITIAL_PAD = 1
|
||||||
|
private const val GROWTH_STEP = 2
|
||||||
|
private const val MAX_PAD = 5
|
||||||
|
private const val WINDOW_EDGE = 2
|
||||||
|
|
||||||
|
/** The reload trigger for a given pad, held strictly inside it. */
|
||||||
|
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which day the split style should select when the grid lands on [month]:
|
||||||
|
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||||
|
* tested without standing up a view model and a provider behind it.
|
||||||
|
*/
|
||||||
|
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
|
today
|
||||||
|
} else {
|
||||||
|
LocalDate(month.year, month.month, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window to load for a visible range, or null to keep the current one.
|
||||||
|
*
|
||||||
|
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||||
|
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||||
|
*/
|
||||||
|
internal fun nextLoadWindow(
|
||||||
|
loaded: IntRange,
|
||||||
|
firstVisible: Int,
|
||||||
|
lastVisible: Int,
|
||||||
|
pad: Int = MAX_PAD,
|
||||||
|
): IntRange? {
|
||||||
|
val edge = edgeForPad(pad)
|
||||||
|
val comfortablyInside =
|
||||||
|
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||||
|
if (comfortablyInside) return null
|
||||||
|
return (firstVisible - pad)..(lastVisible + pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continuous grid addresses months by an absolute index, so the list has one
|
||||||
|
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||||
|
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||||
|
*
|
||||||
|
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||||
|
* the week-start preference — changing it reflows the rows *inside* a month
|
||||||
|
* block but never moves the block, so a scroll position stays put.
|
||||||
|
*/
|
||||||
|
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||||
|
private const val MONTH_INDEX_END_YEAR = 2100
|
||||||
|
|
||||||
|
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||||
|
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||||
|
|
||||||
|
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||||
|
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||||
|
Month.entries[index % 12],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousMonthCount(): Int =
|
||||||
|
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each month occupies two LazyColumn items — its sticky header and the block of
|
||||||
|
* week rows under it — so the two coordinate spaces differ by a factor of two.
|
||||||
|
* Scrolling to a month means scrolling to its header.
|
||||||
|
*/
|
||||||
|
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
|
||||||
|
|
||||||
|
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a load window inside the months that actually exist. The pad
|
||||||
|
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||||
|
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||||
|
*/
|
||||||
|
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||||
|
val last = continuousMonthCount() - 1
|
||||||
|
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||||
|
* blocks to key by, and a row's identity has to survive the months around it
|
||||||
|
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||||
|
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||||
|
* item indices and week indices are the same number.
|
||||||
|
*
|
||||||
|
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||||
|
* why the list state is keyed on it.
|
||||||
|
*/
|
||||||
|
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||||
|
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||||
|
|
||||||
|
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||||
|
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||||
|
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||||
|
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||||
|
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||||
|
|
||||||
|
/** Which month a Dense row belongs to, for reporting the visible range. */
|
||||||
|
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||||
|
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||||
|
// most of it, the same rule the title uses.
|
||||||
|
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||||
|
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every week index the months in [window] touch — the Dense rows one month-window
|
||||||
|
* load covers. Widened by a week at each end so the boundary rows either side are
|
||||||
|
* laid out too, rather than flickering as placeholders.
|
||||||
|
*/
|
||||||
|
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||||
|
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
||||||
|
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||||
|
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||||
|
val first = firstOfMonth(ym)
|
||||||
|
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
return (leadOffset + daysInMonth + 6) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip everything outside [month] from a boundary week row, for the continuous
|
||||||
|
* style's self-contained month blocks.
|
||||||
|
*
|
||||||
|
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
|
||||||
|
* the grid decides which cells to draw blank — but the neighbour month's events
|
||||||
|
* are removed: its pills and counts go, and a bar reaching in from (or out into)
|
||||||
|
* it is cut back to the month's own columns. A bar cut this way keeps a flat
|
||||||
|
* cap, which is what says "this continues past the block" rather than "it ends
|
||||||
|
* here".
|
||||||
|
*/
|
||||||
|
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
||||||
|
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
|
||||||
|
val firstCol = inMonth.indexOfFirst { it }
|
||||||
|
val lastCol = inMonth.indexOfLast { it }
|
||||||
|
// Can't happen for a row of the month's own grid, but a caller-proof no-op
|
||||||
|
// beats an out-of-range crash.
|
||||||
|
if (firstCol == -1) {
|
||||||
|
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
|
||||||
|
}
|
||||||
|
val spans = week.spans.mapNotNull { span ->
|
||||||
|
val start = maxOf(span.startCol, firstCol)
|
||||||
|
val end = minOf(span.endCol, lastCol)
|
||||||
|
if (start > end) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
span.copy(
|
||||||
|
startCol = start,
|
||||||
|
endCol = end,
|
||||||
|
continuesLeft = span.continuesLeft || start > span.startCol,
|
||||||
|
continuesRight = span.continuesRight || end < span.endCol,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
|
||||||
|
return week.copy(
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = week.timedByDay.filterKeys { it in own },
|
||||||
|
countByDay = week.countByDay.filterKeys { it in own },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Month view lays itself out (#38, #53).
|
||||||
|
*
|
||||||
|
* One setting rather than two independent toggles: "vertical scrolling" and
|
||||||
|
* "month plus agenda" would otherwise multiply into four combinations to build
|
||||||
|
* and test, most of which nobody asked for.
|
||||||
|
*
|
||||||
|
* [Split] does **not** replace or disable the Agenda view — that stays a forward
|
||||||
|
* multi-day window with its own range model, while the split pane lists a single
|
||||||
|
* selected day.
|
||||||
|
*/
|
||||||
|
enum class MonthViewStyle {
|
||||||
|
/** Month pages, swiped left/right. Full event bars and pills per day. */
|
||||||
|
Paged,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Months stacked into one vertical scroll, each a self-contained block under
|
||||||
|
* its own sticky header: a block shows only its own days, and whitespace
|
||||||
|
* separates it from the next (#38).
|
||||||
|
*/
|
||||||
|
Continuous,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
|
||||||
|
* where months flow into each other. Each date appears exactly once — paging
|
||||||
|
* repeats a boundary week at both ends — and the 1st names its month, the
|
||||||
|
* only marker of where one ends.
|
||||||
|
*/
|
||||||
|
Dense,
|
||||||
|
|
||||||
|
/** Compact dots-only grid over a list of the selected day's events (#53). */
|
||||||
|
Split,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both vertically scrolling styles, which share a data window and a list state. */
|
||||||
|
val MonthViewStyle.isScrolling: Boolean
|
||||||
|
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged_summary
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense_summary
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split_summary
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Month view style chooser (#38, #53).
|
||||||
|
*
|
||||||
|
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||||
|
* one, so the choice is made by looking rather than by reading "Continuous" and
|
||||||
|
* guessing. Selecting therefore applies immediately and leaves the picker open —
|
||||||
|
* closing on tap would hide the very thing the screen is for. Back exits, the
|
||||||
|
* same as the App name picker, which stays open for the same reason.
|
||||||
|
*
|
||||||
|
* Under the preview sits the selected style's own one-line blurb, then the
|
||||||
|
* family's standard picker shape: connected single-line grouped rows with a
|
||||||
|
* tonal highlight and [SelectedCheck] on the current one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthViewStylePicker(
|
||||||
|
selected: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "month-style-preview",
|
||||||
|
) { shown ->
|
||||||
|
// The preview renders the real grid at phone size and scales it
|
||||||
|
// down, so its own corners are square — the frame rounds it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
MonthStylePreview(
|
||||||
|
style = shown,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The selected style's own blurb lives here, under its preview, rather
|
||||||
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
|
// describe exactly what is being shown above.
|
||||||
|
PickerDescription(stringResource(selected.descriptionRes))
|
||||||
|
options.forEachIndexed { index, style ->
|
||||||
|
val isSelected = style == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(style.labelRes),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
// Applies straight away; the preview above is the confirmation,
|
||||||
|
// so there is nothing to dismiss for.
|
||||||
|
onClick = { onSelect(style) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
@@ -125,6 +126,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
|||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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.ReorderableColumn
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
@@ -847,12 +849,24 @@ private fun ViewsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
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))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -898,6 +912,16 @@ private fun ViewsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMonthStyle) {
|
||||||
|
MonthViewStylePicker(
|
||||||
|
selected = state.monthViewStyle,
|
||||||
|
// The preview is a real grid, so it uses the real week start too.
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onDismiss = { showMonthStyle = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
/** 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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -51,6 +52,8 @@ data class SettingsUiState(
|
|||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
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. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** 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.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
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.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
@@ -150,8 +151,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
// View customisation (#24) folded into one flow so it fits this
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// group — the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
combine(
|
||||||
ViewCustomization(quickSwitch, drawer)
|
prefs.quickSwitchConfig,
|
||||||
|
prefs.drawerViewOrder,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
) { quickSwitch, drawer, monthStyle ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -173,6 +178,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -286,6 +292,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private data class ViewCustomization(
|
private data class ViewCustomization(
|
||||||
val quickSwitch: QuickSwitchConfig,
|
val quickSwitch: QuickSwitchConfig,
|
||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** 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) } }
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -94,6 +92,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -296,8 +295,6 @@ private fun WeekContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -329,20 +326,7 @@ private fun WeekContent(
|
|||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
|
|||||||
@@ -372,6 +372,20 @@
|
|||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_views">Views</string>
|
<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="month_style_paged">Pages</string>
|
||||||
|
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
|
||||||
|
<string name="month_style_continuous">Scrolling months</string>
|
||||||
|
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
|
||||||
|
<string name="month_style_dense">Seamless weeks</string>
|
||||||
|
<string name="month_style_dense_summary">The weeks run on without a break, each month flowing straight into the next with no gap between them.</string>
|
||||||
|
<string name="month_style_split">Split</string>
|
||||||
|
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
|
||||||
|
<string name="month_split_no_events">Nothing scheduled</string>
|
||||||
|
<string name="month_split_expand">Show the whole month</string>
|
||||||
|
<string name="month_split_collapse">Show the day\'s events</string>
|
||||||
<string name="settings_quick_switch_header">Quick-switch button</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_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>
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
@@ -411,7 +425,7 @@
|
|||||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<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_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -125,6 +126,30 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
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
|
@Test
|
||||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What makes the continuous style's month blocks self-contained: a boundary week
|
||||||
|
* keeps its seven columns but carries only the block's own month.
|
||||||
|
*/
|
||||||
|
class ClipWeekToMonthTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val jul26 = YearMonth(2026, Month.JULY)
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A",
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T",
|
||||||
|
start = date.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */
|
||||||
|
private fun firstRowOfJuly(events: List<EventInstance>) =
|
||||||
|
clipWeekToMonth(
|
||||||
|
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(),
|
||||||
|
jul26,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the row keeps all seven days so the columns don't shift`() {
|
||||||
|
val week = firstRowOfJuly(emptyList())
|
||||||
|
assertThat(week.days).hasSize(7)
|
||||||
|
assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the neighbour month's events are dropped`() {
|
||||||
|
val june = timed(LocalDate(2026, 6, 30), 9)
|
||||||
|
val july = timed(LocalDate(2026, 7, 1), 9, id = 3L)
|
||||||
|
val week = firstRowOfJuly(listOf(june, july))
|
||||||
|
|
||||||
|
assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30))
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull()
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar reaching in from the previous month is cut back to the 1st`() {
|
||||||
|
// Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row.
|
||||||
|
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2))))
|
||||||
|
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st
|
||||||
|
assertThat(span.endCol).isEqualTo(3)
|
||||||
|
// Flat cap on the cut side: the event continues out of this block.
|
||||||
|
assertThat(span.continuesLeft).isTrue()
|
||||||
|
assertThat(span.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar living entirely in the neighbour month disappears`() {
|
||||||
|
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30))))
|
||||||
|
assertThat(week.spans).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar reaching out into the next month is cut at the last day`() {
|
||||||
|
// Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2).
|
||||||
|
val weeks = layoutMonthWeeks(
|
||||||
|
jul26,
|
||||||
|
DayOfWeek.MONDAY,
|
||||||
|
listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
val span = clipWeekToMonth(weeks.last(), jul26).spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th
|
||||||
|
assertThat(span.endCol).isEqualTo(4) // Friday the 31st
|
||||||
|
assertThat(span.continuesRight).isTrue()
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row wholly inside the month is untouched`() {
|
||||||
|
val mid = layoutMonthWeeks(
|
||||||
|
jul26,
|
||||||
|
DayOfWeek.MONDAY,
|
||||||
|
listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))),
|
||||||
|
zone,
|
||||||
|
)[1]
|
||||||
|
assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continuous grid's coordinate space. Every block's identity — and the
|
||||||
|
* LazyColumn items it maps to — hangs off this arithmetic, so it gets its own
|
||||||
|
* tests rather than being exercised only through the UI.
|
||||||
|
*/
|
||||||
|
class ContinuousMonthIndexTest {
|
||||||
|
|
||||||
|
private val jun26 = YearMonth(2026, Month.JUNE)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `consecutive months are consecutive indices`() {
|
||||||
|
val jun = monthIndexOf(jun26)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1)
|
||||||
|
// And across the year boundary.
|
||||||
|
assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY)))
|
||||||
|
.isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index round-trips back to its month`() {
|
||||||
|
listOf(
|
||||||
|
YearMonth(1900, Month.JANUARY),
|
||||||
|
jun26,
|
||||||
|
YearMonth(2026, Month.DECEMBER),
|
||||||
|
YearMonth(2100, Month.DECEMBER),
|
||||||
|
).forEach { ym ->
|
||||||
|
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `indices are non-negative and span 1900 through 2100`() {
|
||||||
|
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
|
||||||
|
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
|
||||||
|
.isEqualTo(continuousMonthCount() - 1)
|
||||||
|
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month maps to its header item and back`() {
|
||||||
|
val index = monthIndexOf(jun26)
|
||||||
|
val item = itemIndexForMonth(index)
|
||||||
|
// Both of a month's items — its sticky header and the block under it —
|
||||||
|
// resolve back to the same month, so a title read off the first visible
|
||||||
|
// item is right wherever the viewport sits inside the block.
|
||||||
|
assertThat(monthIndexForItem(item)).isEqualTo(index)
|
||||||
|
assertThat(monthIndexForItem(item + 1)).isEqualTo(index)
|
||||||
|
assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week rows follow the month's shape and its week start`() {
|
||||||
|
// June 2026 starts on a Monday: 30 days → 5 rows either way.
|
||||||
|
assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||||
|
assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5)
|
||||||
|
// February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4
|
||||||
|
// rows, a Monday-anchored one needs 5 — the block's height moves with the
|
||||||
|
// preference even though its position in the list doesn't.
|
||||||
|
val feb26 = YearMonth(2026, Month.FEBRUARY)
|
||||||
|
assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||||
|
assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||||
|
// February 2021 starts on a Monday and is 28 days → exactly 4.
|
||||||
|
assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the placeholder block is the size the loaded one will be`() {
|
||||||
|
// Otherwise scrolling across an unloaded month jumps when its data lands.
|
||||||
|
(0 until 24).forEach { offset ->
|
||||||
|
val ym = yearMonthForIndex(monthIndexOf(jun26) + offset)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
assertThat(weekRowsInMonth(ym, ws))
|
||||||
|
.isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a window is clamped to the months that exist`() {
|
||||||
|
// The regression this guards: an empty visible-items list reported as
|
||||||
|
// "month 0", whose padded window ran to -4 — and yearMonthForIndex has
|
||||||
|
// no month for that, so the whole grid failed with "couldn't read".
|
||||||
|
assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4)
|
||||||
|
val last = continuousMonthCount() - 1
|
||||||
|
assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last)
|
||||||
|
assertThat(clampMonthWindow(10..20)).isEqualTo(10..20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every index a clamped window can hold resolves to a month`() {
|
||||||
|
listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3)))
|
||||||
|
.forEach { window ->
|
||||||
|
window.forEach { index -> yearMonthForIndex(index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the dense week window covers every week its months touch`() {
|
||||||
|
// One month-window load has to lay out every Dense row those months
|
||||||
|
// appear in, boundary weeks included, or rows would flicker as
|
||||||
|
// placeholders while the data for them is already in hand.
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST))
|
||||||
|
val weeks = weekWindowFor(window, ws)
|
||||||
|
window.forEach { index ->
|
||||||
|
val ym = yearMonthForIndex(index)
|
||||||
|
val first = weekIndexOf(firstOfMonth(ym), ws)
|
||||||
|
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
|
||||||
|
assertThat(weeks.contains(first)).isTrue()
|
||||||
|
assertThat(weeks.contains(last)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dense row reports the month that owns most of it`() {
|
||||||
|
// June 29 – July 5, 2026 (Monday-anchored): four of its days are July's,
|
||||||
|
// so the window follows July rather than snapping back to June.
|
||||||
|
val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY)
|
||||||
|
assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY)))
|
||||||
|
// A row wholly inside June reports June.
|
||||||
|
assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(monthIndexOf(jun26))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every day of a dense week shares one index`() {
|
||||||
|
val mon = LocalDate(2026, 6, 8)
|
||||||
|
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||||
|
assertThat(indices.toSet()).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dense week indices round-trip and stay inside the list`() {
|
||||||
|
val wed = LocalDate(2026, 6, 10)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
val index = weekIndexOf(wed, ws)
|
||||||
|
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
||||||
|
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||||
|
assertThat(index).isLessThan(continuousWeekCount(ws))
|
||||||
|
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
||||||
|
.isLessThan(continuousWeekCount(ws))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firstOfMonth is the 1st`() {
|
||||||
|
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a window comfortably around the visible range is kept`() {
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||||
|
// Within two months of the top edge → reload, padded on both sides.
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
|
||||||
|
.isEqualTo(-4..7)
|
||||||
|
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
||||||
|
.isEqualTo(93..105)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a jump far outside the window reloads around the destination`() {
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
||||||
|
.isEqualTo(495..506)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||||
|
// Otherwise every scroll frame would re-trigger a query — and with the
|
||||||
|
// window growing from a pad of 1, this has to hold at every rung of the
|
||||||
|
// ladder, not just the widest one.
|
||||||
|
(1..8).forEach { pad ->
|
||||||
|
val loaded = (10 - pad)..(10 + pad)
|
||||||
|
val past = 10 + pad + 1
|
||||||
|
val widened = nextLoadWindow(loaded, past, past, pad)!!
|
||||||
|
assertThat(nextLoadWindow(widened, past, past, pad)).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reload trigger stays inside the pad`() {
|
||||||
|
// A trigger at or beyond the pad would fire again the instant its own
|
||||||
|
// reload landed, and the window would query forever.
|
||||||
|
(1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) }
|
||||||
|
// A one-month window has no room for hysteresis: reload only on contact.
|
||||||
|
assertThat(edgeForPad(1)).isEqualTo(0)
|
||||||
|
assertThat(edgeForPad(0)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the smallest window reloads on crossing rather than nearing its edge`() {
|
||||||
|
// The first frame loads one month either side of today, which leaves no
|
||||||
|
// room to reload *before* the edge: at pad 1 the trigger is contact.
|
||||||
|
// Only momentary — the first completed load widens the pad to 3, which
|
||||||
|
// buys the usual head start back.
|
||||||
|
val initial = 10..12
|
||||||
|
assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull()
|
||||||
|
assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1))
|
||||||
|
.isEqualTo(12..14)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection follows the month, landing on today when it's there`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection falls to the 1st of any other month`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
|
||||||
|
.isEqualTo(LocalDate(2026, 7, 1))
|
||||||
|
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
|
||||||
|
.isEqualTo(LocalDate(2025, 6, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
|
||||||
|
* is that cheapness changes *nothing*: a row laid out from the index must be the
|
||||||
|
* row that would have come from scanning every instance.
|
||||||
|
*
|
||||||
|
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
|
||||||
|
* disagree — the case that has bitten this codebase before (#65).
|
||||||
|
*/
|
||||||
|
class DayIndexTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.of("Europe/Berlin")
|
||||||
|
private val mon = LocalDate(2026, 6, 8)
|
||||||
|
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
|
||||||
|
|
||||||
|
private var nextId = 0L
|
||||||
|
|
||||||
|
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
|
||||||
|
val id = ++nextId
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T$id",
|
||||||
|
start = from.atTime(startHour, 0).toInstant(zone),
|
||||||
|
end = to.atTime(endHour, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
|
||||||
|
val id = ++nextId
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A$id",
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every shape the layout distinguishes, plus the boundary cases around them. */
|
||||||
|
private fun awkwardEvents(): List<EventInstance> = listOf(
|
||||||
|
timed(mon, 9, mon, 10),
|
||||||
|
// Crosses midnight into the next day.
|
||||||
|
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
|
||||||
|
// Ends exactly at midnight: covers its own day, not the next.
|
||||||
|
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||||
|
// Zero length, at midnight: covers nothing at all.
|
||||||
|
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||||
|
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
|
||||||
|
// Runs in from before the row and out past its end.
|
||||||
|
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
|
||||||
|
// Wholly outside the row, both sides.
|
||||||
|
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
|
||||||
|
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
|
||||||
|
// Two on one day, to pin the ordering the index hands back.
|
||||||
|
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
|
||||||
|
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row from the index is the row from a full scan`() {
|
||||||
|
val events = awkwardEvents()
|
||||||
|
val scanned = layoutCalendarWeek(week, events, zone)
|
||||||
|
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
|
||||||
|
assertThat(indexed).isEqualTo(scanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a whole month agrees, row for row`() {
|
||||||
|
val events = awkwardEvents()
|
||||||
|
val jun = YearMonth(2026, Month.JUNE)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
|
||||||
|
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event covering several of a row's days is listed once`() {
|
||||||
|
// It is bucketed on each date it covers, so the row would otherwise see
|
||||||
|
// it repeatedly and lay out a lane per copy.
|
||||||
|
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
|
||||||
|
val events = DayIndex(listOf(long), zone).eventsOn(week)
|
||||||
|
assertThat(events).containsExactly(long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days no event touches come back empty`() {
|
||||||
|
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
|
||||||
|
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
|
||||||
|
assertThat(index.eventsOn(emptyList())).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty calendar indexes to nothing`() {
|
||||||
|
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's dots morph into the paged style's bars (#53): both
|
||||||
|
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
|
||||||
|
*/
|
||||||
|
class LaneEventsTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val jul26 = YearMonth(2026, Month.JULY)
|
||||||
|
|
||||||
|
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||||
|
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||||
|
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
|
||||||
|
EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A$id",
|
||||||
|
start = from.atTime(0, 0).toInstant(zone),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||||
|
isAllDay = true,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T$id",
|
||||||
|
start = date.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar keeps its lane and the day's timed events fill what's left`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||||
|
val week = rowOfJuly6(listOf(bar, meeting))
|
||||||
|
|
||||||
|
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||||
|
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||||
|
.containsExactly(bar, meeting)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day the bar misses seats its own events from lane zero`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||||
|
val week = rowOfJuly6(listOf(bar, monday))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
|
.containsExactly(monday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day bar is seated on every day it covers`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val week = rowOfJuly6(listOf(bar))
|
||||||
|
|
||||||
|
(1..3).forEach { col ->
|
||||||
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
|
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||||
|
}
|
||||||
|
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||||
|
@Test
|
||||||
|
fun `events sharing a colour each keep their own lane`() {
|
||||||
|
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
|
||||||
|
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||||
|
val week = rowOfJuly6(listOf(first, second))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
|
.containsExactly(first, second)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seating stops at the cap and leaves the rest to the overflow count`() {
|
||||||
|
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||||
|
val week = rowOfJuly6(events)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
|
assertThat(seated).hasSize(3)
|
||||||
|
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar parked below the cap is out of view, so it takes no dot with it. */
|
||||||
|
@Test
|
||||||
|
fun `a bar beyond the cap is left out`() {
|
||||||
|
val bars = (0 until 4).map {
|
||||||
|
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||||
|
}
|
||||||
|
val week = rowOfJuly6(bars)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
|
assertThat(seated).hasSize(3)
|
||||||
|
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||||
|
.containsNoneIn(seated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BLUE = 0xFF3366CC.toInt()
|
||||||
|
const val RED = 0xFFCC3333.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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