release: v2.11.0 #48
@@ -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] (1–365 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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user