feat: show calendar-week numbers in Month view (#25)

Add an opt-in left gutter to the Month grid showing the ISO calendar-week
number, gated by a new "Week numbers" display setting (default off). The
number is computed on each row's first day — the same basis as the Week
view's badge — so the two views agree, and rendered as a low-emphasis
onSurfaceVariant label so it recedes across all six rows rather than
competing with the event bars. The weekday header reserves a matching
gutter so the day columns stay aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:02:44 +02:00
parent 60fcc6b64c
commit a514b8b506
8 changed files with 231 additions and 113 deletions

View File

@@ -200,6 +200,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled } store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
} }
/**
* Whether the Month grid shows the calendar-week (ISO) number in a left
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
* cells slightly. The Week view shows its number unconditionally.
*/
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
}
suspend fun setShowWeekNumbers(enabled: Boolean) {
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
}
/** /**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the * [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -659,6 +672,7 @@ class SettingsPrefs @Inject constructor(
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")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")

View File

@@ -108,6 +108,7 @@ fun MonthScreen(
val month by viewModel.month.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming // The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the // is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick). // screen while the setting is off (it stays null regardless of the tick).
@@ -211,11 +212,12 @@ fun MonthScreen(
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize(), .fillMaxSize(),
) { ) {
WeekdayHeader(weekStart = weekStart) WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent( MonthContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
@@ -231,6 +233,7 @@ fun MonthScreen(
private fun MonthContent( private fun MonthContent(
state: MonthUiState, state: MonthUiState,
slideDir: Int, slideDir: Int,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -276,6 +279,7 @@ private fun MonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid( is MonthUiState.Success -> MonthGrid(
state = s, state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
) )
} }
@@ -325,7 +329,7 @@ private fun MonthTopBar(
} }
@Composable @Composable
private fun WeekdayHeader(weekStart: DayOfWeek) { private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
val locale = currentLocale() val locale = currentLocale()
val days = remember(weekStart, locale) { val days = remember(weekStart, locale) {
(0 until 7).map { offset -> (0 until 7).map { offset ->
@@ -337,6 +341,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
days.forEach { dow -> days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -354,6 +360,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
private val EVENT_ROW_HEIGHT = 20.dp private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left gutter holding the calendar-week number (#25). */
private val WEEK_NUMBER_GUTTER = 24.dp
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp private val CELL_GAP = 2.dp
@@ -363,6 +371,7 @@ private const val MAX_EVENT_ROWS = 3
@Composable @Composable
private fun MonthGrid( private fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
) { ) {
Column( Column(
@@ -376,6 +385,7 @@ private fun MonthGrid(
week = week, week = week,
today = state.today, today = state.today,
month = state.month, month = state.month,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -397,6 +407,7 @@ private fun MonthWeekRow(
week: MonthWeek, week: MonthWeek,
today: LocalDate, today: LocalDate,
month: YearMonth, month: YearMonth,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -404,7 +415,23 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
BoxWithConstraints(modifier) { Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below
// divide the remaining width — the absolute bar offsets stay correct
// because they're measured inside the grid box, not the whole row.
if (showWeekNumbers) {
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.width(WEEK_NUMBER_GUTTER)
.fillMaxHeight(),
)
}
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
val colW = maxWidth / 7 val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the // Per-day background pills — same surfaceContainer rounded surface the
@@ -532,6 +559,39 @@ private fun MonthWeekRow(
} }
} }
} }
}
/**
* Left-gutter calendar-week number (#25), aligned with the day-number band. The
* ISO week-of-week-based-year computed on the row's first day — the same basis
* as the Week view's badge — so the two views agree. A low-emphasis label rather
* than a filled badge: it repeats on all six rows, so it must recede.
*/
@Composable
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) {
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
}
val label = stringResource(R.string.week_number_label)
Column(
modifier = modifier
.padding(top = CELL_TOP_PADDING)
.semantics { contentDescription = "$label $weekNumber" },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.height(DAY_NUMBER_HEIGHT),
contentAlignment = Alignment.Center,
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable @Composable
private fun DayNumberCell( private fun DayNumberCell(

View File

@@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
/** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -613,6 +613,18 @@ private fun AppearanceScreen(
position = Position.Middle, position = Position.Middle,
onClick = { showWeekStart = true }, onClick = { showWeekStart = true },
) )
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_time_format), title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat), summary = timeFormatLabel(state.timeFormat),

View File

@@ -33,6 +33,8 @@ data class SettingsUiState(
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */ /** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false, val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */ /** How far ahead the in-app Agenda screen shows events (v2.11). */
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). */

View File

@@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange, prefs.agendaScreenRange,
prefs.agendaWidgetRange, prefs.agendaWidgetRange,
prefs.timeFormat, prefs.timeFormat,
prefs.showHourLines, // Two grid-display toggles folded into one flow so they fit this
) { view, screenRange, widgetRange, timeFormat, showHourLines -> // group — the outer combine is already at its five-arg limit.
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
)
}, },
combine( combine(
prefs.agendaShowRangeBar, prefs.agendaShowRangeBar,
@@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor(
agendaWidgetRange = views.agendaWidgetRange, agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat, timeFormat = views.timeFormat,
showHourLines = views.showHourLines, showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor(
val agendaWidgetRange: AgendaRange, val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref, val timeFormat: TimeFormatPref,
val showHourLines: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowHourLines(enabled) } viewModelScope.launch { prefs.setShowHourLines(enabled) }
} }
fun setShowWeekNumbers(enabled: Boolean) {
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) { fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch { viewModelScope.launch {
prefs.setPastEventDisplay(mode) prefs.setPastEventDisplay(mode)

View File

@@ -287,6 +287,8 @@
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string> <string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string> <string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string> <string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_time_format">Time format</string> <string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string> <string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string> <string name="settings_time_format_12h">12-hour (2:00 PM)</string>

View File

@@ -95,6 +95,14 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue() assertThat(prefs.showHourLines.first()).isTrue()
} }
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showWeekNumbers.first()).isFalse()
prefs.setShowWeekNumbers(true)
assertThat(prefs.showWeekNumbers.first()).isTrue()
}
@Test @Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest { fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))