Compare commits

..

15 Commits

Author SHA1 Message Date
3ca7200624 Merge pull request 'feat(agenda): limit how far ahead the agenda screen and widget show (#4)' (!44) from feat/agenda-range-limit into release/v2.11.0 2026-06-27 15:08:34 +00:00
c30a153552 fix(widget): drive agenda range via reactive Glance state
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m22s
The agenda widget loaded and sliced its data in the provideGlance preamble
and captured it as a non-reactive local. updateAll() reliably recomposes a
live Glance session but does not reliably re-run that preamble, so a range
change was redrawn against the stale slice (intermittently — only when no
session was alive did it pick up the new range). This is the same platform
limitation the month widget already works around.

Mirror that pattern: load the widest selectable window once, store the range
in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via
currentState, and slice in the composition. The settings setter writes the
state into each instance and recomposes, so a range change now reflects via
plain recomposition regardless of session lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:03:48 +02:00
bb92d02000 fix(widget): refresh from setter, serialized with a mutex
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle
could swallow a change after re-entering the app, so a single range change
sometimes didn't refresh the widget. Refresh directly from the settings
setters instead (always fires on a real change), serialized through a mutex
so a rapid flip-and-flip-back can't run two updateAll calls at once and
strand the widget on the intermediate value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:44:52 +02:00
cd703e9d54 fix(widget): debounce widget refresh on pref change
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m38s
Refreshing the widget directly from the setter raced when a setting was
flipped and flipped back quickly: two concurrent updateAll calls could
coalesce around a stale read and leave the widget on the intermediate value.
Observe the agenda-widget-range and week-start prefs instead and push a
single debounced updateAll once changes settle, so the widget always
converges to the final value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:37:57 +02:00
f27b0e269d fix(widget): refresh agenda widget when its range (or week start) changes
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m18s
The agenda widget only re-read agendaWidgetRange on the next data-change /
midnight / periodic refresh (or re-placement), so a settings change appeared
to do nothing until then. Push an updateAll from the settings setters:
the agenda widget on an agenda-widget-range change, and both widgets on a
week-start change (month weekday header + the agenda widget's "this week").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:30:27 +02:00
0f31981d26 refactor(agenda): one toggle for the whole range bar
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m43s
Consolidate the separate "range title" and "range button" settings into a
single "Range bar" toggle, now that the header and switcher live on one bar.
On by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:25:46 +02:00
6471c48528 refactor(agenda): put range header and pill on one bar
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m46s
Move the session range pill from the bottom-left corner onto the same top
bar as the "Showing all upcoming events for …" header — header on the left,
pill on the right, each still independently toggleable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:22:33 +02:00
f8a2569831 feat(agenda): warmer empty state + "upcoming" wording
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m30s
Reword the range header to "Showing all upcoming events for". Replace the
dry empty state ("Nothing scheduled" + subtitle) with a single warmer line
("You're all caught up"), drop the subtitle, and switch the icon to a
coffee cup. The empty title is shared with the agenda widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:20:33 +02:00
6ba7f38887 feat(agenda): range header banner + toggles for banner and pill
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
Move the "Showing all events for <window>" line out of the picker and into
the agenda view as an always-visible header above the list. Add two
settings (Agenda group, both ON by default) to toggle the range header
banner and the bottom-left range pill independently. The picker keeps its
two-list grouping but no longer carries the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:23 +02:00
366059b012 feat(agenda): split range picker into two lists with a concrete header
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m27s
Group the range options into calendar-aligned (today / this week / this
month) and rolling (next 7 / 30 days / custom) lists. Add a header showing
the concrete span currently in effect — a single date for Today, the month
and year for This month, otherwise a start–end span — so it's clear what
the agenda is showing. The header appears only where a window is supplied
(the agenda pill), not in Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:59:56 +02:00
3cf1439850 feat(agenda): bottom-left pill to override the range for the session
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m4s
Add a tonal pill in the agenda's bottom-left corner showing the current
range; tapping it opens the existing AgendaRangePicker as a session-only
override. The override lives in AgendaViewModel (in-memory), so it survives
view switches and rotation but resets to the saved default when the app is
relaunched. The pill fills with the primary container while an override is
active, and a hint in the picker spells out the temporary nature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:50:18 +02:00
b2bfdc0f42 refactor(settings): regroup Appearance into theme / calendar / agenda
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m0s
Drop the uniform two-per-card grouping for meaningful sections: theme &
colour (2), calendar — default view, week start, time format, hour lines
(4), and agenda (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:36:19 +02:00
7c9830deda Merge release/v2.11.0 into feat/agenda-range-limit
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m26s
Integrate #3/#5/#6 (week-start, time-format, hour-lines) into the agenda
range branch. SettingsViewModel folds all five view prefs into one combine;
WidgetData keeps the range-based window and adds the resolved is24Hour.

Also, in the same Appearance section:
- regroup the rows into four titled-by-spacing groups (theme & colour /
  calendar layout / timeline display / agenda) instead of one long card.
- add an explanatory description line under the title of each agenda-range
  picker (PickerDescription).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:31:44 +02:00
766c2ffcf8 feat(agenda): add calendar-aligned "this week"/"this month" ranges
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m22s
Extend AgendaRange with calendar-aligned windows alongside the rolling
ones: ThisWeek runs through the end of the current week (respecting the
week-start preference — a Monday start means everything before next
Monday), ThisMonth through the last day of the current month. dayCount now
takes the anchor day and week-start; the agenda screen and widget resolve
the week-start preference and pass it through. Rolling options relabelled
("Today", "Next 7 days", "Next 30 days") to read distinctly from the new
calendar-aligned ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:25:59 +02:00
add88fbadf feat(agenda): limit how far ahead the agenda screen and widget show
Add independent agendaScreenRange and agendaWidgetRange preferences, each a
rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month).
Rolling (not calendar-aligned) so the span never degenerates near a period
boundary. The in-app Agenda screen and the agenda widget each read their own
setting, configured via a new AgendaRangePicker with an inline custom-days
editor.

Closes #4 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:15:46 +02:00
18 changed files with 875 additions and 52 deletions

View File

@@ -5,6 +5,7 @@ title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--

View File

@@ -3,7 +3,7 @@ name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- enhancement
- feat
---
### What would you like Calendula to do?

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Calendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -7,6 +7,9 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -112,6 +115,40 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
* widget's [agendaWidgetRange].
*/
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaScreenRange(range: AgendaRange) {
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
}
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaWidgetRange(range: AgendaRange) {
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
}
/**
* Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON.
*/
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
}
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
@@ -301,6 +338,9 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* 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 {
/** Allowed bounds for a [Custom] day count. */
const val MIN_CUSTOM_DAYS = 1
const val MAX_CUSTOM_DAYS = 365
}
}
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 -> 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)
}
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
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"
}
/** Parse a stored value; unknown/garbage falls back to [default]. */
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) {
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
} else {
default
}
}
else -> default
}
/**
* The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header:
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026")
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
locale: Locale,
): 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 medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
return when (range) {
AgendaRange.Day -> medium.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}"
}
}
private const val CUSTOM_PREFIX = "CUSTOM:"

View File

@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
@@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -47,6 +54,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
@@ -91,11 +100,13 @@ fun AgendaScreen(
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
val isOnToday = when (val s = state) {
is AgendaUiState.Success -> s.anchor == s.today
else -> true
}
val successState = state as? AgendaUiState.Success
ModalNavigationDrawer(
drawerState = drawerState,
@@ -138,18 +149,129 @@ fun AgendaScreen(
)
},
) { innerPadding ->
AgendaContent(
state = state,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
}
}
if (showRangePicker) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month,
onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false },
)
}
}
/**
* A compact tonal pill showing the agenda's current range. Tapping it opens the
* range picker as a session-only override. Filled with the primary container
* while an override is active, so the temporary state is obvious.
*/
@Composable
private fun AgendaRangePill(
range: AgendaRange,
isOverride: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val container = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val content = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = container,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
/**
* A header naming the concrete window currently shown, e.g. "Showing all events
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026".
*/
@Composable
private fun AgendaRangeBanner(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
modifier: Modifier = Modifier,
) {
val locale = currentLocale()
val window = agendaRangeWindowSummary(range, start, end, locale)
Column(modifier = modifier) {
Text(
text = stringResource(R.string.agenda_range_showing_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${agendaRangeLabel(range)}, $window",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun AgendaContent(
state: AgendaUiState,
@@ -257,7 +379,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.EventAvailable,
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
@@ -268,13 +390,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.agenda_empty_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
val anchor: LocalDate,
val today: LocalDate,
val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
val range: AgendaRange,
/** True when [range] is a temporary in-view override of the saved default. */
val rangeIsOverride: Boolean,
/** Last day the current [range] covers (inclusive), for the range header. */
val rangeEnd: LocalDate,
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
val showRangeBar: Boolean,
) : AgendaUiState
}

View File

@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
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
@@ -17,10 +19,13 @@ 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.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
@@ -30,16 +35,24 @@ import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** How far ahead the agenda loads events from its anchor day. */
internal const val AGENDA_WINDOW_DAYS = 60
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
// The saved agenda range plus the range-bar visibility toggle.
private val agendaSettings = combine(
settingsPrefs.agendaScreenRange,
settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) }
// 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
@@ -48,14 +61,32 @@ class AgendaViewModel @Inject constructor(
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> = _anchor
.flatMapLatest { anchor ->
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
// session but resets to the saved default when the app is relaunched.
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = anchor,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
showRangeBar = settings.showBar,
)
}
.flatMapLatest { params ->
val window = agendaRange(
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine(
repository.calendars(),
repository.instances(range),
repository.instances(window),
) { calendars, instances ->
buildState(anchor, calendars, instances)
buildState(params, calendars, instances)
}
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -75,16 +106,47 @@ class AgendaViewModel @Inject constructor(
_anchor.value = date
}
/** Temporarily override the agenda range for this session (the bottom-left pill). */
fun setRangeOverride(range: AgendaRange) {
_rangeOverride.value = range
}
private data class AgendaSettings(
val range: AgendaRange,
val showBar: Boolean,
)
private data class AgendaParams(
val anchor: LocalDate,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
)
private fun buildState(
anchor: LocalDate,
params: AgendaParams,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone)
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,
days = days,
range = params.range,
rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd,
showRangeBar = params.showRangeBar,
)
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -37,6 +39,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
@@ -260,6 +263,17 @@ private fun CustomReminderEditor(
}
}
/** A short explanatory paragraph shown under a picker's title, above the rows. */
@Composable
private fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun SelectedCheck() {
Icon(
@@ -269,6 +283,149 @@ private fun SelectedCheck() {
)
}
/**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365).
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*/
@Composable
fun AgendaRangePicker(
title: String,
description: String,
selected: AgendaRange,
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
val rollingRowCount = rolling.size + 1 // + the custom row
val customSelected = selected is AgendaRange.Custom
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description)
// Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, calendarAligned.size))
}
Spacer(Modifier.height(12.dp))
// Rolling windows + custom.
rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount))
}
GroupedRow(
title = if (customSelected) {
agendaRangeLabel(selected)
} else {
stringResource(R.string.agenda_range_custom)
},
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomDaysEditor(
amountText = amountText,
onAmountChange = { amountText = it },
onConfirm = { days ->
onSelect(AgendaRange.Custom(days))
onDismiss()
},
)
}
}
}
/** The expanded "Custom" day-count editor: an amount field (1365) and Set. */
@Composable
private fun CustomDaysEditor(
amountText: String,
onAmountChange: (String) -> Unit,
onConfirm: (Int) -> Unit,
) {
val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30",
)
Spacer(Modifier.width(16.dp))
Text(
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { days?.let(onConfirm) },
enabled = days != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
@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)
}
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)

View File

@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -430,23 +432,20 @@ private fun AppearanceScreen(
var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
) {
// Theme & colour
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Middle,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (state.dynamicColorAvailable) {
@@ -454,7 +453,7 @@ private fun AppearanceScreen(
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Middle,
position = Position.Bottom,
trailing = {
Switch(
checked = state.dynamicColor,
@@ -468,6 +467,16 @@ private fun AppearanceScreen(
null
},
)
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart),
@@ -492,6 +501,34 @@ private fun AppearanceScreen(
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
Spacer(Modifier.height(16.dp))
// Agenda
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
}
if (showTheme) {
@@ -514,6 +551,24 @@ private fun AppearanceScreen(
onDismiss = { showWeekStart = false },
)
}
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
)
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showTimeFormat) {
OptionPicker(
title = stringResource(R.string.settings_time_format),

View File

@@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
@@ -23,6 +24,12 @@ data class SettingsUiState(
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Optional event-form fields shown by default (rest behind "more fields"). */

View File

@@ -1,9 +1,14 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -12,7 +17,12 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -21,12 +31,15 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -74,13 +87,24 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
},
combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat },
prefs.showHourLines,
) { base, defaults, overrides, viewAndTimeFormat, showHourLines ->
combine(
prefs.defaultView,
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
prefs.showHourLines,
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
},
prefs.agendaShowRangeBar,
) { base, defaults, overrides, views, showRangeBar ->
base.copy(
defaultView = viewAndTimeFormat.first,
timeFormat = viewAndTimeFormat.second,
showHourLines = showHourLines,
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
agendaShowRangeBar = showRangeBar,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -96,6 +120,12 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially,
// the last call reads the final committed pref and renders it.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults(
val allowColor: Boolean,
val defaultReminder: Int?,
@@ -110,6 +140,14 @@ class SettingsViewModel @Inject constructor(
val calendars: List<CalendarSource>,
)
private data class ViewSettings(
val defaultView: CalendarView,
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -119,7 +157,40 @@ class SettingsViewModel @Inject constructor(
}
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) }
viewModelScope.launch {
prefs.setWeekStart(pref)
// Both widgets depend on the week start (month weekday header; the
// agenda widget's "this week" range).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setAgendaScreenRange(range: AgendaRange) {
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
}
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push the range into each instance's Glance state and recompose.
// The widget reads it via currentState, so this reflects reliably
// even when updateAll only recomposes a live session (it does not
// re-run provideGlance's data preamble).
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowRangeBar(enabled: Boolean) {
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setTimeFormat(pref: TimeFormatPref) {

View File

@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import kotlinx.coroutines.flow.first
@@ -24,9 +25,6 @@ import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
/** How far ahead the agenda widget loads (a month of upcoming events). */
private const val AGENDA_WIDGET_DAYS = 30
/**
* How far either side of today the month widget pre-loads. The displayed month
* is chosen reactively in the composition, so one wide read covers ~13 months of
@@ -49,9 +47,19 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData
data class Ready(
val today: LocalDate,
/**
* Days with events across the **widest** selectable window. The displayed
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
) : AgendaWidgetData
}
@@ -98,13 +106,22 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
val instances = ep.calendarRepository().instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
val is24Hour = ep.settingsPrefs().timeFormat.first()
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour,
weekStart = weekStart,
savedRange = savedRange,
)
}

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
@@ -41,6 +44,9 @@ import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -63,8 +69,20 @@ import java.util.Locale
* of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
/**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings
* change is reflected by plain recomposition — `updateAll` does NOT reliably
* re-run the `provideGlance` preamble for a live widget session, so loading the
* range there and slicing it would silently keep the stale value (mirrors the
* month widget's MONTH_INDEX_KEY approach).
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and
@@ -102,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
is AgendaWidgetData.Ready ->
if (data.days.isEmpty()) {
is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
val visibleDays = data.days.filter { it.date <= rangeEnd }
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
data.days.forEach { day ->
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) }
}
@@ -121,6 +148,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
}
}
}
}
}
}
}

View File

@@ -230,8 +230,7 @@
<string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<string name="agenda_empty_title">Alles erledigt</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
@@ -282,6 +281,25 @@
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
<string name="settings_hour_lines">Stundenlinien</string>
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
<string name="settings_agenda_range">Agenda-Zeitraum</string>
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</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>
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
<plurals name="agenda_range_days">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
</plurals>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>

View File

@@ -231,8 +231,7 @@
<string name="agenda_today_action">Today</string>
<string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">Nothing scheduled</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -275,6 +274,25 @@
<string name="settings_time_format_24h">24-hour (14:00)</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</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>
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
<plurals name="agenda_range_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
@@ -89,6 +90,18 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
prefs.setAgendaWidgetRange(AgendaRange.Week)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
}
@Test
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)

View File

@@ -0,0 +1,83 @@
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 `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(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,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
).forEach { range ->
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
.isEqualTo(range)
}
}
@Test
fun `custom range round-trips through storage`() {
val range = AgendaRange.Custom(14)
assertThat(range.storageValue()).isEqualTo("CUSTOM:14")
assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day))
.isEqualTo(AgendaRange.Custom(14))
}
@Test
fun `garbage and null fall back to the default`() {
assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month)
assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week)
assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day)
}
@Test
fun `out-of-range custom value clamps on parse`() {
assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day))
.isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS))
}
}