feat(agenda): anchor today with an empty-day card (#35)

Add an "Always show today" setting (Settings → Agenda, on by default) that
keeps today as the first entry in both the Agenda screen and its home-screen
widget even once nothing is left today. Under today's normal header a small
"No more events today" card appears — the coffee-cup empty-state motif in the
app, a rounded surface in the widget — so the first rows you see are clearly
today's rather than a future day's.

The anchor is a pure, JVM-tested helper (anchorTodayIfMissing) applied after
past-event filtering; in-app it only kicks in when the window starts on today,
never on a jumped-to date. The widget reads the pref reactively via per-instance
Glance state, mirroring the range/past-event settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:39:27 +02:00
parent 1b731a4ab0
commit b9329f6fb6
12 changed files with 290 additions and 30 deletions

View File

@@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too.
- Keep today at the top of the agenda. A new **Always show today** setting
(Settings → Agenda, on by default) anchors today as the first entry in both the
Agenda screen and its home-screen widget even once nothing is left today —
under today's header a small "No more events today" card appears — so the first
events you see are clearly today's rather than a future day's. Turn it off to
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
### Fixed
- Reminders for events on another day no longer read as if they were today. A
@@ -945,6 +951,7 @@ automatically, with zero telemetry and no internet permission.
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47

View File

@@ -252,6 +252,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* Whether the agenda (both the in-app screen and the home-screen widget)
* always anchors today at the top with a "nothing left today" placeholder,
* even once today has no remaining events (issue #35). Default ON — makes
* today's events easy to tell apart from a future day's at a glance.
*/
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
}
suspend fun setAgendaShowToday(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_TODAY_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
@@ -673,6 +687,8 @@ class SettingsPrefs @Inject constructor(
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 AGENDA_SHOW_TODAY_KEY =
booleanPreferencesKey("agenda_show_today")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")

View File

@@ -24,6 +24,7 @@ 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.Card
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -106,6 +107,7 @@ fun AgendaScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -190,6 +192,7 @@ fun AgendaScreen(
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
@@ -289,6 +292,7 @@ private fun AgendaRangeBanner(
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
@@ -304,7 +308,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -312,6 +316,14 @@ private fun AgendaContent(
} else {
state.days
}
// Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in
// it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing(
days = filtered,
today = state.today,
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
@@ -349,6 +361,12 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
}
} else {
itemsIndexed(
items = day.events,
key = { _, event -> event.instanceId },
@@ -361,6 +379,7 @@ private fun AgendaList(
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -391,6 +410,42 @@ private fun AgendaDayHeader(
}
}
/**
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
* visible slot when nothing is left rather than a bare header.
*/
@Composable
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp, horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.agenda_no_more_today),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun AgendaEventRow(
event: EventInstance,

View File

@@ -41,6 +41,24 @@ fun groupAgendaDays(
)
}
/**
* Ensure [today] surfaces as the first agenda day even when it carries no
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top
* so a glance tells today's events apart from a future day's (issue #35).
*/
fun anchorTodayIfMissing(
days: List<AgendaDay>,
today: LocalDate,
enabled: Boolean,
): List<AgendaDay> =
if (enabled && days.none { it.date == today }) {
listOf(AgendaDay(today, emptyList())) + days
} else {
days
}
/**
* State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear).

View File

@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW,
)
/**
* Whether to keep today anchored at the top with a "nothing left today"
* placeholder even once it has no remaining events (#35). A display concern
* applied in the composition (after past-event filtering), so it rides
* alongside the data state like [pastEventDisplay] rather than re-querying.
*/
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate

View File

@@ -631,6 +631,18 @@ private fun AppearanceScreen(
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_show_today),
summary = stringResource(R.string.settings_agenda_show_today_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.agendaShowToday,
onCheckedChange = viewModel::setAgendaShowToday,
)
},
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),

View File

@@ -39,6 +39,8 @@ data class SettingsUiState(
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true,
/** 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). */

View File

@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
@@ -116,14 +117,20 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
// Two grid-display toggles folded into one flow so they fit this
// group — the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
::Triple,
),
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
showHourLines = toggles.first,
showWeekNumbers = toggles.second,
agendaShowToday = toggles.third,
)
},
combine(
@@ -147,6 +154,7 @@ class SettingsViewModel @Inject constructor(
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -220,6 +228,7 @@ class SettingsViewModel @Inject constructor(
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
)
private data class MiscSettings(
@@ -398,6 +407,22 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// The agenda widget reads this reactively, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}

View File

@@ -66,6 +66,12 @@ sealed interface AgendaWidgetData {
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
@@ -117,6 +123,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.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
@@ -132,6 +139,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
now = Clock.System.now(),
)
}

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.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
@@ -48,6 +49,7 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow
}
@Composable
@@ -139,13 +151,17 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
@@ -156,20 +172,28 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
} else {
days
}
}
},
today = data.today,
enabled = showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(it)) }
}
}
}
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
@@ -262,6 +286,44 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
)
}
/**
* A card under an anchored, event-less today (#35) — echoing the widget's
* full-empty message ("You're all caught up" here becomes "No more events
* today") in a rounded surface so today's slot never looks like a bare header.
*/
@Composable
private fun PlaceholderRow(date: LocalDate) {
val context = androidx.glance.LocalContext.current
Box(
modifier = GlanceModifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.fillMaxWidth()
.background(GlanceTheme.colors.surfaceVariant)
.cornerRadius(16.dp)
.padding(vertical = 18.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 14.sp,
),
)
}
}
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current

View File

@@ -255,6 +255,7 @@
<string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_no_more_today">No more events today</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -321,6 +322,8 @@
<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_show_today">Always show today</string>
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</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>

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Covers the agenda widget's "always show today" anchor logic (#35). */
class AnchorTodayTest {
private val today = LocalDate(2026, 6, 17)
private val tomorrow = LocalDate(2026, 6, 18)
@Test
fun `disabled leaves the days untouched even when today is absent`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
}
@Test
fun `enabled prepends an empty today when it is missing`() {
val days = listOf(AgendaDay(tomorrow, emptyList()))
val result = anchorTodayIfMissing(days, today, enabled = true)
assertThat(result).hasSize(2)
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
assertThat(result[1]).isEqualTo(days.first())
}
@Test
fun `enabled anchors today into an otherwise empty list`() {
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
}
@Test
fun `enabled is a no-op when today already has its own day`() {
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
}
}