Compare commits

...

4 Commits

Author SHA1 Message Date
5377a2b466 fix(calendar): align the week-number gutter with the top bar hamburger
The month grid was inset 4dp while its weekday header (and the loading grid) used
8dp, so the week-number column — and the day cells under their labels — sat 4dp
left of where they should. Bring the grid to 8dp: the gutter centre now lands on
the hamburger (4dp bar inset + 24dp half icon), and day cells sit under their
weekday labels.

The week view's header badge and hour labels had the same drift (a 48dp edge-to-
edge gutter centres its content at 24dp, not 28dp). Its top section background
bleeds full-width when scrolled, so instead of insetting the whole content, give
just the gutter content an 8dp start inset to centre it on the hamburger too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
e73148dc6c fix(widget): keep the empty-today line plain, not a card
The agenda widget's event rows are plain (a colour stripe + text, no card), so
the rounded "No more events today" surface looked out of place. Render it as a
muted line indented to the event titles instead, matching the widget's style.
The in-app agenda keeps its coffee-cup card, where event rows are cards too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:15:51 +02:00
396a5610aa fix(agenda): localize dates and refine the range bar
Localize every agenda date via a new localizedDateFormatter helper that lays a
field skeleton out in the locale's own order (Android best-pattern), instead of
a hardcoded day-month-year layout: the range-window summary, the screen's day
headers, and the widget's day headers. This also fixes the window mixing two
orders (e.g. "15 Jul – Aug 13, 2026").

Refine the range bar: the banner drops the range name (it already sits on the
selector button beside it) and shows just the concrete dates; the selector keeps
a subtle neutral surface tint — distinct from the top-bar view switcher's
secondary container so the two don't compete — and its right edge lines up with
the switcher. The anchored empty-today card takes a single event row's resting
corner radius.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:02:37 +02:00
b9329f6fb6 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>
2026-07-15 18:39:27 +02:00
16 changed files with 361 additions and 90 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` a couple more file labels the same calendar data arrives under (`.vcs`
vCalendar files and the `application/ics` type), so opening or sharing those vCalendar files and the `application/ics` type), so opening or sharing those
into Calendula works too. 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 ### Fixed
- Reminders for events on another day no longer read as if they were today. A - 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 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37 [#37]: https://codeberg.org/jlmakiola/calendula/issues/37
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39 [#39]: https://codeberg.org/jlmakiola/calendula/issues/39
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40 [#40]: https://codeberg.org/jlmakiola/calendula/issues/40
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46 [#46]: https://codeberg.org/jlmakiola/calendula/issues/46
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#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 } 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 calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change * 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_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_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_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 TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")

View File

@@ -1,10 +1,9 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
/** /**
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
/** /**
* The concrete span the range covers, starting at [start] through [end] * The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header: * (inclusive), for a human-readable header. Both ends use the same day-month
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026") * order so a span never mixes "15 Jul" with "Aug 13, 2026":
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026") * - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026") * - everything else → "start end" ("27 Jun 3 Jul 2026"), with the start's
* year shown too only when it differs from the end's.
*/ */
fun agendaRangeWindowSummary( fun agendaRangeWindowSummary(
range: AgendaRange, range: AgendaRange,
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) 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 javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when (range) { return when (range) {
AgendaRange.Day -> medium.format(javaStart) AgendaRange.Day -> dayMonthYear.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart) AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}" else -> {
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
"${startFmt.format(javaStart)} ${dayMonthYear.format(javaEnd)}"
}
} }
} }

View File

@@ -15,16 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee 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.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -65,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -83,7 +85,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
@@ -106,6 +107,7 @@ fun AgendaScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -170,9 +172,11 @@ fun AgendaScreen(
successState?.takeIf { it.showRangeBar }?.let { s -> successState?.takeIf { it.showRangeBar }?.let { s ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -190,6 +194,7 @@ fun AgendaScreen(
AgendaContent( AgendaContent(
state = state, state = state,
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -213,9 +218,11 @@ fun AgendaScreen(
} }
/** /**
* A compact tonal pill showing the agenda's current range. Tapping it opens the * The agenda's current range, tapped to open the range picker as a session-only
* range picker as a session-only override. Filled with the primary container * override. Shares the top-bar view switcher's button shape so the two read as a
* while an override is active, so the temporary state is obvious. * family, but stays low-emphasis — a subtle neutral surface tint rather than the
* switcher's secondary container, so it doesn't compete. Fills with the primary
* container only while an override is active, to make that temporary state clear.
*/ */
@Composable @Composable
private fun AgendaRangePill( private fun AgendaRangePill(
@@ -224,43 +231,34 @@ private fun AgendaRangePill(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val container = if (isOverride) { FilledTonalButton(
MaterialTheme.colorScheme.primaryContainer onClick = onClick,
} else { shape = MaterialTheme.shapes.large,
MaterialTheme.colorScheme.surfaceContainerHigh colors = ButtonDefaults.filledTonalButtonColors(
} containerColor = if (isOverride) {
val content = if (isOverride) { MaterialTheme.colorScheme.primaryContainer
MaterialTheme.colorScheme.onPrimaryContainer } else {
} else { MaterialTheme.colorScheme.surfaceContainerHigh
MaterialTheme.colorScheme.onSurfaceVariant },
} contentColor = if (isOverride) {
Surface( MaterialTheme.colorScheme.onPrimaryContainer
color = container, } else {
contentColor = content, MaterialTheme.colorScheme.onSurfaceVariant
shape = RoundedCornerShape(50), },
modifier = modifier.clickable(onClick = onClick), ),
modifier = modifier,
) { ) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = agendaRangeLabel(range),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), style = MaterialTheme.typography.labelLarge,
) { )
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 * A header naming the concrete window currently shown under a "showing …" label,
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026". * e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
* lives on the selector button beside it, so it isn't repeated here.
*/ */
@Composable @Composable
private fun AgendaRangeBanner( private fun AgendaRangeBanner(
@@ -277,8 +275,10 @@ private fun AgendaRangeBanner(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// Just the concrete dates — the range's name ("Next 30 days") already
// sits on the selector button to the right, so repeating it here is noise.
Text( Text(
text = "${agendaRangeLabel(range)}, $window", text = window,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@@ -289,6 +289,7 @@ private fun AgendaRangeBanner(
private fun AgendaContent( private fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay, pastDisplay: PastEventDisplay,
showToday: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -304,7 +305,7 @@ private fun AgendaContent(
// Hiding drops finished events — and any day they leave empty; dimming // Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall // keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open. // 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 -> state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) } val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining) if (remaining.isEmpty()) null else day.copy(events = remaining)
@@ -312,6 +313,14 @@ private fun AgendaContent(
} else { } else {
state.days 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()) { if (days.isEmpty()) {
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
@@ -349,17 +358,24 @@ private fun AgendaList(
stickyHeader(key = "header-${day.date}") { stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
} }
itemsIndexed( if (day.events.isEmpty()) {
items = day.events, // An anchored, event-less today (#35) — "nothing left today".
key = { _, event -> event.instanceId }, item(key = "placeholder-${day.date}") {
) { index, event -> AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
AgendaEventRow( }
event = event, } else {
position = positionOf(index, day.events.size), itemsIndexed(
dimmed = dimPast && event.hasEnded(now), items = day.events,
modifier = animateItemMotion(), key = { _, event -> event.instanceId },
onClick = { onEventClick(event) }, ) { 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)) } item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
} }
@@ -391,6 +407,44 @@ 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(
// Match a single event row's resting corner radius (floret groupedShape).
shape = RoundedCornerShape(22.dp),
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 @Composable
private fun AgendaEventRow( private fun AgendaEventRow(
event: EventInstance, event: EventInstance,
@@ -520,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return "$weekday, ${date.day}. $monthName ${date.year}" return localizedDateFormatter(locale, "EEEdMMMy").format(java)
} }

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 * State for the Agenda view: a flat, forward-looking list of upcoming events
* grouped by day (only days that actually have events appear). * grouped by day (only days that actually have events appear).

View File

@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
initialValue = PastEventDisplay.SHOW, 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 zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
/** /**
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
} }
} }
/**
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
* (via Android's best-pattern matching), so dates read naturally per locale
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
*/
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
locale,
)

View File

@@ -380,7 +380,10 @@ private fun MonthGrid(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 4.dp, vertical = 4.dp), // Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->

View File

@@ -631,6 +631,18 @@ private fun AppearanceScreen(
position = Position.Middle, position = Position.Middle,
onClick = { showAgendaWidgetRange = true }, 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( GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar), title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint), summary = stringResource(R.string.settings_agenda_range_bar_hint),

View File

@@ -39,6 +39,8 @@ data class SettingsUiState(
val agendaScreenRange: AgendaRange = AgendaRange.Month, val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */ /** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month, 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). */ /** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true, val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */ /** 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.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY 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_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.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -116,14 +117,20 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange, prefs.agendaScreenRange,
prefs.agendaWidgetRange, prefs.agendaWidgetRange,
prefs.timeFormat, prefs.timeFormat,
// Two grid-display toggles folded into one flow so they fit this // Display toggles folded into one flow so they fit this group —
// group — the outer combine is already at its five-arg limit. // the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair), combine(
) { view, screenRange, widgetRange, timeFormat, gridToggles -> prefs.showHourLines,
prefs.showWeekNumbers,
prefs.agendaShowToday,
::Triple,
),
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings( ViewSettings(
view, screenRange, widgetRange, timeFormat, view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first, showHourLines = toggles.first,
showWeekNumbers = gridToggles.second, showWeekNumbers = toggles.second,
agendaShowToday = toggles.third,
) )
}, },
combine( combine(
@@ -147,6 +154,7 @@ class SettingsViewModel @Inject constructor(
timeFormat = views.timeFormat, timeFormat = views.timeFormat,
showHourLines = views.showHourLines, showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers, showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -220,6 +228,7 @@ class SettingsViewModel @Inject constructor(
val timeFormat: TimeFormatPref, val timeFormat: TimeFormatPref,
val showHourLines: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean, val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -398,6 +407,22 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) } 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) { fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) } viewModelScope.launch { prefs.setTimeFormat(pref) }
} }

View File

@@ -114,6 +114,10 @@ import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
* (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp
private val MIN_EVENT_HEIGHT = 24.dp private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -448,9 +452,10 @@ private fun WeekDayHeader(
.padding(top = 4.dp, bottom = 8.dp), .padding(top = 4.dp, bottom = 8.dp),
) { ) {
// Mirror the day-column layout (empty weekday line + spacer) so the // Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. // badge lines up vertically with the date numbers. The start inset centres
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
Column( Column(
modifier = Modifier.width(GUTTER_WIDTH), modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text(text = " ", style = MaterialTheme.typography.labelSmall) Text(text = " ", style = MaterialTheme.typography.labelSmall)
@@ -621,10 +626,12 @@ private fun Timeline(
// soft corners are permanent at any scroll position (not just at the // soft corners are permanent at any scroll position (not just at the
// day's start/end). // day's start/end).
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns) // Hour gutter (scrolls in sync with the day columns). Same start inset
// as the header badge so the labels sit under it and on the hamburger.
Column( Column(
modifier = Modifier modifier = Modifier
.width(GUTTER_WIDTH) .width(GUTTER_WIDTH)
.padding(start = GUTTER_CONTENT_START_INSET)
.fillMaxHeight() .fillMaxHeight()
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {

View File

@@ -66,6 +66,12 @@ sealed interface AgendaWidgetData {
val savedRange: AgendaRange, val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */ /** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay, 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. */ /** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant, val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
@@ -117,6 +123,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val prefs = ep.settingsPrefs() val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first() val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first() val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in // Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain // the composition from Glance state, so changing the range is a plain
@@ -132,6 +139,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
weekStart = weekStart, weekStart = weekStart,
savedRange = savedRange, savedRange = savedRange,
savedPastDisplay = savedPastDisplay, savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
now = Clock.System.now(), 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.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
@@ -48,11 +49,13 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange 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.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
/** /**
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
*/ */
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display") 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() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
private sealed interface AgendaRow { private sealed interface AgendaRow {
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val event: EventInstance) : 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 @Composable
@@ -139,37 +151,49 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay = val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus( val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1, range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
// Slice to the chosen range, then drop finished events (and any 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. // 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
.filter { it.date <= rangeEnd } // "always show today" pref is on and today has no events left (#35).
.let { days -> val visibleDays = anchorTodayIfMissing(
if (pastDisplay == PastEventDisplay.HIDE) { days = data.days
days.mapNotNull { day -> .filter { it.date <= rangeEnd }
val remaining = day.events.filterNot { it.hasEnded(data.now) } .let { days ->
if (remaining.isEmpty()) null else day.copy(events = remaining) 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()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title) WidgetMessage(R.string.agenda_empty_title)
} else { } else {
val rows = buildList { val rows = buildList {
visibleDays.forEach { day -> visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today)) 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()) { LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index -> items(rows.size) { index ->
when (val row = rows[index]) { when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today) is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
is AgendaRow.Event -> EventRow( is AgendaRow.Event -> EventRow(
event = row.event, event = row.event,
dark = dark, dark = dark,
@@ -262,6 +286,28 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
) )
} }
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable
private fun PlaceholderRow(date: LocalDate) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
modifier = GlanceModifier
.fillMaxWidth()
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@Composable @Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) { private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
@@ -337,9 +383,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
} }
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) // Weekday + date in the locale's own field order (compact: no year), rather
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) // than a hardcoded day-month layout.
val formatted = "$weekday, ${date.day} $monthName" val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
return if (relative != null) "$relative · $formatted" else formatted return if (relative != null) "$relative · $formatted" else formatted
} }

View File

@@ -255,6 +255,7 @@
<string name="agenda_header_today">Today</string> <string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string> <string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">You\'re all caught up</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 --> <!-- Event search -->
<string name="search_action">Search</string> <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_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">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_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">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="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_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)
}
}