From ce7df7f1d7307567ef471145dfdfb77eb957a0bb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 13:29:31 +0200 Subject: [PATCH] Use kotlinx-datetime's YearMonth and java.time converters --- .../calendula/ui/agenda/AgendaRange.kt | 9 ++-- .../calendula/ui/agenda/AgendaRows.kt | 3 +- .../calendula/ui/month/MonthScreen.kt | 25 +++++----- .../calendula/ui/month/MonthStylePreview.kt | 6 +-- .../calendula/ui/month/MonthViewModel.kt | 47 ++++++++----------- .../calendula/ui/settings/ViewsSettings.kt | 3 +- .../calendula/ui/week/WeekScreen.kt | 8 ++-- .../ui/month/ContinuousMonthIndexTest.kt | 8 ++-- 8 files changed, 55 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt index a8e5f397..645a93e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index e690aa3e..7940d2b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 03e4291a..ca58c5f9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index 67f13db0..f27dcc6f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 71a757b0..4ea30348 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = _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 { - 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 { - 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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 2dac5c61..b214efc5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -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 = 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()) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 66fe65d5..2097fbd9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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", diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index e939b9b2..c05cd3ac 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -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