release: v2.11.0 #48

Merged
makiolaj merged 35 commits from release/v2.11.0 into main 2026-06-27 21:49:33 +00:00
7 changed files with 114 additions and 27 deletions
Showing only changes of commit 766c2ffcf8 - Show all commits

View File

@@ -1,15 +1,26 @@
package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
/**
* How far ahead the agenda (screen or widget) shows events, as a rolling window
* starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom]
* is an arbitrary day count. Rolling (not calendar-aligned) windows so the span
* never degenerates near a week/month boundary.
* How far ahead the agenda (screen or widget) shows events, starting at today.
*
* Two flavours:
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
* [Month] (30 days), [Custom] (1365 days). These never degenerate.
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
* through the end of the current week (respecting the week-start preference —
* a Monday start means "everything before next Monday"); [ThisMonth] runs
* through the last day of the current month. These shrink as the period ends.
*/
sealed interface AgendaRange {
data object Day : AgendaRange
data object Week : AgendaRange
data object Month : AgendaRange
data object ThisWeek : AgendaRange
data object ThisMonth : AgendaRange
data class Custom(val days: Int) : AgendaRange
companion object {
@@ -19,11 +30,27 @@ sealed interface AgendaRange {
}
}
/** Inclusive number of days the window spans, starting at (and including) today. */
fun AgendaRange.dayCount(): Int = when (this) {
private const val DAYS_PER_WEEK = 7
/**
* Inclusive number of days the window spans, starting at (and including)
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
* [AgendaRange.ThisWeek], the [weekStart] preference.
*/
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
AgendaRange.Day -> 1
AgendaRange.Week -> 7
AgendaRange.Week -> DAYS_PER_WEEK
AgendaRange.Month -> 30
AgendaRange.ThisWeek -> {
// Days elapsed since the week's first day (0 when today *is* the start),
// so the window is the rest of the week through the day before it repeats.
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
}
@@ -32,6 +59,8 @@ fun AgendaRange.storageValue(): String = when (this) {
AgendaRange.Day -> "DAY"
AgendaRange.Week -> "WEEK"
AgendaRange.Month -> "MONTH"
AgendaRange.ThisWeek -> "THIS_WEEK"
AgendaRange.ThisMonth -> "THIS_MONTH"
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
}
@@ -40,6 +69,8 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
stored == "DAY" -> AgendaRange.Day
stored == "WEEK" -> AgendaRange.Week
stored == "MONTH" -> AgendaRange.Month
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
if (days != null) {

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
@@ -41,6 +44,10 @@ class AgendaViewModel @Inject constructor(
private val screenRange = settingsPrefs.agendaScreenRange
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
@@ -50,9 +57,11 @@ class AgendaViewModel @Inject constructor(
val anchor: StateFlow<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> =
combine(_anchor, screenRange) { anchor, range -> anchor to range }
.flatMapLatest { (anchor, range) ->
val window = agendaRange(anchor, range.dayCount() - 1, zone)
combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart ->
Triple(anchor, range, weekStart)
}
.flatMapLatest { (anchor, range, weekStart) ->
val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone)
combine(
repository.calendars(),
repository.instances(window),

View File

@@ -283,7 +283,13 @@ fun AgendaRangePicker(
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month)
val presets = listOf(
AgendaRange.Day,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
AgendaRange.Week,
AgendaRange.Month,
)
val customSelected = selected is AgendaRange.Custom
val rowCount = presets.size + 1 // + the custom row
@@ -390,6 +396,8 @@ private fun CustomDaysEditor(
@Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)

View File

@@ -90,8 +90,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
val range = ep.settingsPrefs().agendaWidgetRange.first()
val window = agendaRange(anchor, range.dayCount() - 1, zone)
val prefs = ep.settingsPrefs()
val range = prefs.agendaWidgetRange.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone)
val instances = ep.calendarRepository().instances(window).first()
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
}

View File

@@ -280,9 +280,11 @@
<string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_agenda_range">Agenda-Zeitraum</string>
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
<string name="agenda_range_day">1 Tag</string>
<string name="agenda_range_week">1 Woche</string>
<string name="agenda_range_month">1 Monat</string>
<string name="agenda_range_day">Heute</string>
<string name="agenda_range_this_week">Diese Woche</string>
<string name="agenda_range_this_month">Dieser Monat</string>
<string name="agenda_range_week">Nächste 7 Tage</string>
<string name="agenda_range_month">Nächste 30 Tage</string>
<string name="agenda_range_custom">Benutzerdefiniert…</string>
<string name="agenda_range_custom_hint">Tage</string>
<plurals name="agenda_range_days">

View File

@@ -273,9 +273,11 @@
<string name="settings_week_start_sunday">Sunday</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="agenda_range_day">1 day</string>
<string name="agenda_range_week">1 week</string>
<string name="agenda_range_month">1 month</string>
<string name="agenda_range_day">Today</string>
<string name="agenda_range_this_week">This week</string>
<string name="agenda_range_this_month">This month</string>
<string name="agenda_range_week">Next 7 days</string>
<string name="agenda_range_month">Next 30 days</string>
<string name="agenda_range_custom">Custom…</string>
<string name="agenda_range_custom_hint">Days</string>
<plurals name="agenda_range_days">

View File

@@ -1,27 +1,60 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class AgendaRangeTest {
// A reference anchor: 2026-06-17 is a Wednesday.
private val wednesday = LocalDate(2026, 6, 17)
@Test
fun `fixed ranges have 1, 7, 30 day counts`() {
assertThat(AgendaRange.Day.dayCount()).isEqualTo(1)
assertThat(AgendaRange.Week.dayCount()).isEqualTo(7)
assertThat(AgendaRange.Month.dayCount()).isEqualTo(30)
fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
}
@Test
fun `this week runs through the day before the week repeats`() {
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
// On the first day of the week the whole 7 days remain.
val monday = LocalDate(2026, 6, 15)
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
}
@Test
fun `this month runs through the last day of the month`() {
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
// February 2026 (non-leap) has 28 days.
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
.isEqualTo(9)
}
@Test
fun `custom day count is clamped to bounds`() {
assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45)
assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
.isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
}
@Test
fun `fixed ranges round-trip through storage`() {
listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range ->
listOf(
AgendaRange.Day,
AgendaRange.Week,
AgendaRange.Month,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
).forEach { range ->
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
.isEqualTo(range)
}