Use kotlinx-datetime's YearMonth and java.time converters

This commit is contained in:
2026-10-16 13:35:45 +02:00
parent cab6a4aaf6
commit ce7df7f1d7
8 changed files with 55 additions and 54 deletions
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/**
* How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
val daysInMonth = anchor.yearMonth.numberOfDays
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
locale: Locale,
monthAsSpan: Boolean = false,
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val javaStart = start.toJavaLocalDate()
val javaEnd = end.toJavaLocalDate()
val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when {
@@ -52,6 +52,7 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
@@ -210,7 +211,7 @@ private fun formatTime(
private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val java = date.toJavaLocalDate()
// 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)
@@ -192,10 +192,13 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.YearMonth
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.toJavaMonth
import kotlinx.datetime.yearMonth
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
@@ -344,7 +347,7 @@ fun MonthScreen(
if (dense) {
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
.plus(3, DateTimeUnit.DAY)
YearMonth(midweek.year, midweek.month)
midweek.yearMonth
} else {
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
}
@@ -393,7 +396,7 @@ fun MonthScreen(
}
}
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
val isOnCurrentMonth = titleMonth == today.yearMonth
var splitExpanded by rememberSaveable(viewStyle) {
mutableStateOf(viewStyle == MonthViewStyle.Paged)
}
@@ -422,7 +425,7 @@ fun MonthScreen(
val jumpToToday: () -> Unit = {
todayPulse++
if (scrolling) {
val targetMonth = YearMonth(today.year, today.month)
val targetMonth = today.yearMonth
pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId
viewModel.goToToday()
@@ -440,13 +443,13 @@ fun MonthScreen(
}
} else {
viewModel.goToToday()
jumpToMonth(YearMonth(today.year, today.month))
jumpToMonth(today.yearMonth)
}
}
// Drawer jump-to-date.
val jumpToDate: (LocalDate) -> Unit = { target ->
if (scrolling) {
val targetMonth = YearMonth(target.year, target.month)
val targetMonth = target.yearMonth
pendingScrollMonth = targetMonth
val requestId = ++scrollRequestId
viewModel.goToDate(target)
@@ -464,7 +467,7 @@ fun MonthScreen(
}
} else {
viewModel.goToDate(target)
jumpToMonth(YearMonth(target.year, target.month))
jumpToMonth(target.yearMonth)
}
}
@@ -548,7 +551,7 @@ fun MonthScreen(
// still settling would land and take the tap back.
onSelectDay = { date ->
viewModel.selectDate(date)
jumpToMonth(YearMonth(date.year, date.month))
jumpToMonth(date.yearMonth)
},
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -721,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
val javaDow = dow.toJavaDayOfWeek()
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium,
@@ -940,7 +943,7 @@ internal fun ContinuousMonthGrid(
modifier: Modifier = Modifier,
) {
val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val todayMonth = remember(state.today) { state.today.yearMonth }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
@@ -2698,7 +2701,7 @@ private fun DayNumberCell(
private fun shortMonthName(date: LocalDate): String {
val locale = currentLocale()
return remember(date.month, locale) {
java.time.Month.of(date.month.ordinal + 1)
date.month.toJavaMonth()
.getDisplayName(JavaTextStyle.SHORT, locale)
}
}
@@ -2861,7 +2864,7 @@ private fun formatMonthTitle(
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
date = ym.firstDay.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -14,10 +14,10 @@ import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlinx.datetime.yearMonth
/**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
state = sample.continuous,
listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)),
monthIndexOf(today.yearMonth),
),
),
showWeekNumbers = false,
@@ -109,7 +109,7 @@ private fun sampleMonthState(
weekStart: DayOfWeek,
zone: TimeZone,
): SampleMonth {
val ym = YearMonth(today.year, today.month)
val ym = today.yearMonth
val first = LocalDate(ym.year, ym.month, 1)
val events = sampleMonthEvents(first, today, zone)
@@ -47,6 +47,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import javax.inject.Inject
import kotlinx.datetime.yearMonth
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
private const val MONTH_CACHE_SPAN = 2
@@ -100,7 +101,7 @@ class MonthViewModel @Inject constructor(
}
.stateWhileSubscribed(viewModelScope, todayDate)
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month
/**
@@ -181,11 +182,11 @@ class MonthViewModel @Inject constructor(
/** Last reported visible range, so a widening step can re-centre on it. */
@Volatile
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
private var visibleMonths = monthIndexOf(todayDate.yearMonth)
.let { it..it }
private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
monthIndexOf(todayDate.yearMonth)
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
)
@@ -201,10 +202,8 @@ class MonthViewModel @Inject constructor(
// 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)).startOfWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
val last = yearMonthForIndex(window.last).lastDay
.startOfWeek(ws)
.plus(6, DateTimeUnit.DAY)
val range = dayRange(first, last, zone)
@@ -305,7 +304,7 @@ class MonthViewModel @Inject constructor(
* silently list a date the grid isn't pointing at.
*/
fun selectDate(date: LocalDate) {
val target = YearMonth(date.year, date.month)
val target = date.yearMonth
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
if (target != _month.value) _month.value = target
}
@@ -320,12 +319,12 @@ class MonthViewModel @Inject constructor(
} else {
val next = selected.plus(days, DateTimeUnit.DAY)
_selectedDate.value = next
_month.value = YearMonth(next.year, next.month)
_month.value = next.yearMonth
}
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
_month.value = todayDate.yearMonth
_selectedDate.value = null
}
@@ -336,8 +335,8 @@ class MonthViewModel @Inject constructor(
*/
private fun followToday(old: LocalDate, new: LocalDate) {
if (viewStyle.value.isScrolling) return
if (_month.value != YearMonth(old.year, old.month)) return
_month.value = YearMonth(new.year, new.month)
if (_month.value != old.yearMonth) return
_month.value = new.yearMonth
_selectedDate.value = null
}
@@ -357,7 +356,7 @@ class MonthViewModel @Inject constructor(
/** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month)
_month.value = date.yearMonth
_selectedDate.value = date
}
@@ -407,7 +406,7 @@ internal fun layoutMonthWeeks(
byDay: DayIndex,
zone: TimeZone,
): List<MonthWeek> {
val gridStart = firstOfMonth(ym).startOfWeek(weekStart)
val gridStart = ym.firstDay.startOfWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row ->
@@ -528,8 +527,7 @@ internal fun monthGridRange(
weekStart: DayOfWeek,
zone: TimeZone,
): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfWeek(weekStart)
val gridStart = ym.firstDay.startOfWeek(weekStart)
return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
}
@@ -558,7 +556,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
* the first of the visible month.
*/
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
if (YearMonth(today.year, today.month) == month) {
if (today.yearMonth == month) {
today
} else {
LocalDate(month.year, month.month, 1)
@@ -626,8 +624,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
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
@@ -658,7 +654,7 @@ 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))
return monthIndexOf(midweek.yearMonth)
}
/**
@@ -667,19 +663,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
* 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)
val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
val lastMonthEnd = yearMonthForIndex(window.last).lastDay
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 first = ym.firstDay
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
return (leadOffset + ym.numberOfDays + 6) / 7
}
/**
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.ReorderableRowHeight
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.DayOfWeek
import java.time.format.TextStyle as JavaTextStyle
import kotlinx.datetime.toJavaDayOfWeek
/**
* Views: everything that changes how a calendar view reads, grouped by the view
@@ -504,7 +505,7 @@ private val WEEK_START_OPTIONS: List<WeekStartPref> =
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised weekday name, so no per-day string resource is needed.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
is WeekStartPref.Day -> pref.day.toJavaDayOfWeek()
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@@ -202,10 +202,12 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlinx.datetime.yearMonth
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
@@ -1084,7 +1086,7 @@ private fun WeekDayHeader(
.fillMaxWidth()
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
) { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val javaDow = date.dayOfWeek.toJavaDayOfWeek()
val isToday = date == today
Column(
modifier = Modifier
@@ -1958,7 +1960,7 @@ private fun formatWeekTitle(
abbreviated: Boolean = false,
): String {
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
date = weekStart.yearMonth.firstDay.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL",
@@ -1974,7 +1976,7 @@ private fun formatDayTitle(
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
date = date.toJavaLocalDate(),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
@@ -121,8 +121,8 @@ class ContinuousMonthIndexTest {
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)
val first = weekIndexOf(ym.firstDay, ws)
val last = weekIndexOf(yearMonthForIndex(index + 1).firstDay.minus(1, DateTimeUnit.DAY), ws)
assertThat(weeks.contains(first)).isTrue()
assertThat(weeks.contains(last)).isTrue()
}
@@ -162,8 +162,8 @@ class ContinuousMonthIndexTest {
}
@Test
fun `firstOfMonth is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
fun `firstDay is the 1st`() {
assertThat(jun26.firstDay).isEqualTo(LocalDate(2026, 6, 1))
}
@Test