From b9329f6fb61c3033a413e2ef8f9a4af9e74ee397 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 15 Jul 2026 18:39:27 +0200 Subject: [PATCH] feat(agenda): anchor today with an empty-day card (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 7 ++ .../calendula/data/prefs/SettingsPrefs.kt | 16 ++++ .../calendula/ui/agenda/AgendaScreen.kt | 79 ++++++++++++++--- .../calendula/ui/agenda/AgendaUiState.kt | 18 ++++ .../calendula/ui/agenda/AgendaViewModel.kt | 13 +++ .../calendula/ui/settings/SettingsScreen.kt | 12 +++ .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 37 ++++++-- .../calendula/widget/WidgetData.kt | 8 ++ .../calendula/widget/agenda/AgendaWidget.kt | 86 ++++++++++++++++--- app/src/main/res/values/strings.xml | 3 + .../calendula/ui/agenda/AnchorTodayTest.kt | 39 +++++++++ 12 files changed, 290 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b40b52b..ce551f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 640d675..7bed479 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -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 = 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") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index effb38b..6d7785c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -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,17 +361,24 @@ private fun AgendaList( stickyHeader(key = "header-${day.date}") { AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) } - itemsIndexed( - items = day.events, - key = { _, event -> event.instanceId }, - ) { index, event -> - AgendaEventRow( - event = event, - position = positionOf(index, day.events.size), - dimmed = dimPast && event.hasEnded(now), - modifier = animateItemMotion(), - onClick = { onEventClick(event) }, - ) + 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 }, + ) { index, event -> + AgendaEventRow( + event = event, + position = positionOf(index, day.events.size), + dimmed = dimPast && event.hasEnded(now), + modifier = animateItemMotion(), + 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 110a528..8e1b5dd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -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, + today: LocalDate, + enabled: Boolean, +): List = + 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). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 5e00ac1..0f8ce1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -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 = settingsPrefs.agendaShowToday + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = true, + ) + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index d56212d..c3a5f10 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 72bdefd..b2f4bf0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -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). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 515f984..17cbfce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index cbce878..a1b06be 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -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(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 5e2184a..ca2ce11 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -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,37 +151,49 @@ 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 - .filter { it.date <= rangeEnd } - .let { days -> - if (pastDisplay == PastEventDisplay.HIDE) { - days.mapNotNull { day -> - val remaining = day.events.filterNot { it.hasEnded(data.now) } - if (remaining.isEmpty()) null else day.copy(events = remaining) + // 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) { + days.mapNotNull { day -> + val remaining = day.events.filterNot { it.hasEnded(data.now) } + if (remaining.isEmpty()) null else day.copy(events = remaining) + } + } else { + days } - } 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)) - day.events.forEach { add(AgendaRow.Event(it)) } + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5683820..4fd47bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,6 +255,7 @@ Today Tomorrow You\'re all caught up + No more events today Search @@ -321,6 +322,8 @@ How far ahead the Agenda screen lists events. Agenda widget range How far ahead the agenda home-screen widget lists events. + Always show today + Keep today at the top of the agenda and its widget, even once nothing is left today. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt new file mode 100644 index 0000000..968b3ce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AnchorTodayTest.kt @@ -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) + } +}