From a514b8b50656a9af1697d4965f97de2ba10e6b62 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:02:44 +0200 Subject: [PATCH 1/3] feat: show calendar-week numbers in Month view (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../calendula/data/prefs/SettingsPrefs.kt | 14 + .../calendula/ui/month/MonthScreen.kt | 280 +++++++++++------- .../calendula/ui/month/MonthViewModel.kt | 8 + .../calendula/ui/settings/SettingsScreen.kt | 12 + .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 18 +- app/src/main/res/values/strings.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 8 + 8 files changed, 231 insertions(+), 113 deletions(-) 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 7f0f122..1553778 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 @@ -200,6 +200,19 @@ class SettingsPrefs @Inject constructor( 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 = 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 * [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 PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") 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 QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 3413ce7..8aa3ace 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -108,6 +108,7 @@ fun MonthScreen( val month by viewModel.month.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.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 // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -211,11 +212,12 @@ fun MonthScreen( .padding(innerPadding) .fillMaxSize(), ) { - WeekdayHeader(weekStart = weekStart) + WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers) CompositionLocalProvider(LocalDimCutoff provides dimCutoff) { MonthContent( state = state, slideDir = slideDir, + showWeekNumbers = showWeekNumbers, onSwipeNext = goNext, onSwipePrev = goPrev, onRetry = jumpToToday, @@ -231,6 +233,7 @@ fun MonthScreen( private fun MonthContent( state: MonthUiState, slideDir: Int, + showWeekNumbers: Boolean, onSwipeNext: () -> Unit, onSwipePrev: () -> Unit, onRetry: () -> Unit, @@ -276,6 +279,7 @@ private fun MonthContent( is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is MonthUiState.Success -> MonthGrid( state = s, + showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, ) } @@ -325,7 +329,7 @@ private fun MonthTopBar( } @Composable -private fun WeekdayHeader(weekStart: DayOfWeek) { +private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { val locale = currentLocale() val days = remember(weekStart, locale) { (0 until 7).map { offset -> @@ -337,6 +341,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) { .fillMaxWidth() .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 -> val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY 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 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 CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp @@ -363,6 +371,7 @@ private const val MAX_EVENT_ROWS = 3 @Composable private fun MonthGrid( state: MonthUiState.Success, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, ) { Column( @@ -376,6 +385,7 @@ private fun MonthGrid( week = week, today = state.today, month = state.month, + showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() @@ -397,6 +407,7 @@ private fun MonthWeekRow( week: MonthWeek, today: LocalDate, month: YearMonth, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { @@ -404,135 +415,184 @@ private fun MonthWeekRow( val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) - BoxWithConstraints(modifier) { - val colW = maxWidth / 7 - - // Per-day background pills — same surfaceContainer rounded surface the - // week/day views use, so the three views share one visual language. - // Spanning bars draw on top of these, bridging cells, so they still read - // as one continuous event. - Row(Modifier.matchParentSize()) { - week.days.forEach { d -> - val inMonth = d.month == month.month && d.year == month.year - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .background( - color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer - else MaterialTheme.colorScheme.surfaceContainerLow, - shape = CELL_SHAPE, - ), - ) - } - } - - Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { - Row(Modifier.fillMaxWidth()) { - week.days.forEach { d -> - DayNumberCell( - date = d, - isToday = d == today, - inMonth = d.month == month.month && d.year == month.year, - modifier = Modifier.weight(1f), - ) - } - } - // Breathing room between the day number (and today's circle) and the - // first event row. - Spacer(Modifier.height(DAY_NUMBER_GAP)) - Box( + 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 - .fillMaxWidth() - .weight(1f) - .clipToBounds(), - ) { - // Spanning bars on their shared lanes. - week.spans.filter { it.lane < shownLanes }.forEach { span -> - val cols = span.endCol - span.startCol + 1 - MonthBar( - event = span.event, - dark = dark, - continuesLeft = span.continuesLeft, - continuesRight = span.continuesRight, - modifier = Modifier - .offset( - x = colW * span.startCol, - y = EVENT_ROW_HEIGHT * span.lane, - ) - .width(colW * cols) - .height(EVENT_ROW_HEIGHT) - .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + .width(WEEK_NUMBER_GUTTER) + .fillMaxHeight(), + ) + } + BoxWithConstraints( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + val colW = maxWidth / 7 + + // Per-day background pills — same surfaceContainer rounded surface the + // week/day views use, so the three views share one visual language. + // Spanning bars draw on top of these, bridging cells, so they still read + // as one continuous event. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + val inMonth = d.month == month.month && d.year == month.year + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background( + color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer + else MaterialTheme.colorScheme.surfaceContainerLow, + shape = CELL_SHAPE, + ), ) } - // Single-day timed pills + overflow, per column. Pills fill the - // lane slots no bar occupies on THIS day (top-most first), so a - // bar-free day isn't pushed down by a multi-day event that only - // sits on other days of the week. - week.days.forEachIndexed { col, d -> - val timed = week.timedByDay[d].orEmpty() - val occupied = week.spans - .filter { it.lane < shownLanes && col in it.startCol..it.endCol } - .map { it.lane } - .toSet() - val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } - val pillsShown = timed.take(freeSlots.size) - pillsShown.forEachIndexed { i, ev -> + } + + Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) { + Row(Modifier.fillMaxWidth()) { + week.days.forEach { d -> + DayNumberCell( + date = d, + isToday = d == today, + inMonth = d.month == month.month && d.year == month.year, + modifier = Modifier.weight(1f), + ) + } + } + // Breathing room between the day number (and today's circle) and the + // first event row. + Spacer(Modifier.height(DAY_NUMBER_GAP)) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clipToBounds(), + ) { + // Spanning bars on their shared lanes. + week.spans.filter { it.lane < shownLanes }.forEach { span -> + val cols = span.endCol - span.startCol + 1 MonthBar( - event = ev, + event = span.event, dark = dark, - continuesLeft = false, - continuesRight = false, + continuesLeft = span.continuesLeft, + continuesRight = span.continuesRight, modifier = Modifier .offset( - x = colW * col, - y = EVENT_ROW_HEIGHT * freeSlots[i], + x = colW * span.startCol, + y = EVENT_ROW_HEIGHT * span.lane, ) - .width(colW) + .width(colW * cols) .height(EVENT_ROW_HEIGHT) .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), ) } - val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size - if (hidden > 0) { - val hiddenColors = buildList { - week.spans - .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event.color) } - timed.drop(pillsShown.size).forEach { add(it.color) } - }.distinct().take(3) - OverflowDots( - colors = hiddenColors, - extra = hidden - hiddenColors.size, - dark = dark, - modifier = Modifier - .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) - .width(colW) - .padding(horizontal = 3.dp), - ) + // Single-day timed pills + overflow, per column. Pills fill the + // lane slots no bar occupies on THIS day (top-most first), so a + // bar-free day isn't pushed down by a multi-day event that only + // sits on other days of the week. + week.days.forEachIndexed { col, d -> + val timed = week.timedByDay[d].orEmpty() + val occupied = week.spans + .filter { it.lane < shownLanes && col in it.startCol..it.endCol } + .map { it.lane } + .toSet() + val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } + val pillsShown = timed.take(freeSlots.size) + pillsShown.forEachIndexed { i, ev -> + MonthBar( + event = ev, + dark = dark, + continuesLeft = false, + continuesRight = false, + modifier = Modifier + .offset( + x = colW * col, + y = EVENT_ROW_HEIGHT * freeSlots[i], + ) + .width(colW) + .height(EVENT_ROW_HEIGHT) + .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), + ) + } + val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size + if (hidden > 0) { + val hiddenColors = buildList { + week.spans + .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } + .forEach { add(it.event.color) } + timed.drop(pillsShown.size).forEach { add(it.color) } + }.distinct().take(3) + OverflowDots( + colors = hiddenColors, + extra = hidden - hiddenColors.size, + dark = dark, + modifier = Modifier + .offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) + .width(colW) + .padding(horizontal = 3.dp), + ) + } } } } - } - // Tap layer: in month view a tap on any day opens that day. Padded and - // clipped to the background pill so the ripple matches it. - Row(Modifier.matchParentSize()) { - week.days.forEach { d -> - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = CELL_GAP, vertical = 1.dp) - .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, - ) + // Tap layer: in month view a tap on any day opens that day. Padded and + // clipped to the background pill so the ripple matches it. + Row(Modifier.matchParentSize()) { + week.days.forEach { d -> + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .clip(CELL_SHAPE) + .clickable { onOpenDay(d) }, + ) + } } } } } +/** + * 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 private fun DayNumberCell( date: LocalDate, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 2804a6b..de51d4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor( initialValue = false, ) + /** Whether to show the calendar-week number gutter (#25; display only). */ + val showWeekNumbers: StateFlow = settingsPrefs.showWeekNumbers + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = false, + ) + private val todayDate: LocalDate get() = Clock.System.now().toLocalDateTime(zone).date 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 29b4e1f..501db1f 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 @@ -613,6 +613,18 @@ private fun AppearanceScreen( position = Position.Middle, 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( title = stringResource(R.string.settings_time_format), summary = timeFormatLabel(state.timeFormat), 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 f45132d..eb8d531 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 @@ -33,6 +33,8 @@ data class SettingsUiState( val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, /** Whether the month/week grids fade events that have already finished. */ 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). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, /** How far ahead the agenda widget shows events (v2.11). */ 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 61f57bd..f1a879a 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 @@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor( prefs.agendaScreenRange, prefs.agendaWidgetRange, prefs.timeFormat, - prefs.showHourLines, - ) { view, screenRange, widgetRange, timeFormat, showHourLines -> - ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) + // 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 -> + ViewSettings( + view, screenRange, widgetRange, timeFormat, + showHourLines = gridToggles.first, + showWeekNumbers = gridToggles.second, + ) }, combine( prefs.agendaShowRangeBar, @@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor( agendaWidgetRange = views.agendaWidgetRange, timeFormat = views.timeFormat, showHourLines = views.showHourLines, + showWeekNumbers = views.showWeekNumbers, agendaShowRangeBar = misc.showRangeBar, autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, @@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor( val agendaWidgetRange: AgendaRange, val timeFormat: TimeFormatPref, val showHourLines: Boolean, + val showWeekNumbers: Boolean, ) private data class MiscSettings( @@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setShowHourLines(enabled) } } + fun setShowWeekNumbers(enabled: Boolean) { + viewModelScope.launch { prefs.setShowWeekNumbers(enabled) } + } + fun setPastEventDisplay(mode: PastEventDisplay) { viewModelScope.launch { prefs.setPastEventDisplay(mode) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19639cb..06e5b19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,8 @@ Couldn\'t read that file as a font Week starts on Automatic + Week numbers + Show calendar-week numbers in month view Time format Automatic 12-hour (2:00 PM) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 8fb656c..41ea2a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -95,6 +95,14 @@ class SettingsPrefsTest { 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 fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) From 8b22e1b2af94dcbb6e00bec28d5557762ba0c630 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:11:02 +0200 Subject: [PATCH 2/3] refactor: share WeekNumberBadge; use it in Month gutter, centered Extract the Week view's calendar-week badge into a shared ui/common component and reuse it for the Month grid's week-number gutter, so the two views show week numbers in the exact same format. The gutter now centres the badge vertically in each row (was pinned to the day-number band) and is widened to seat the badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/WeekNumberBadge.kt | 38 +++++++++++++++++++ .../calendula/ui/month/MonthScreen.kt | 34 ++++++----------- .../calendula/ui/week/WeekScreen.kt | 21 +--------- 3 files changed, 51 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt new file mode 100644 index 0000000..771adf9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * Calendar-week badge — a filled tonal chip with a bold number, deliberately set + * apart from the surrounding day numbers. Shared by the Week and Month views so + * the week-of-year indicator reads identically wherever it appears. + */ +@Composable +fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { + val label = stringResource(R.string.week_number_label) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 8aa3ace..f73a5f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -73,6 +73,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -360,8 +361,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { private val EVENT_ROW_HEIGHT = 20.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 +/** Width of the optional left gutter holding the calendar-week badge (#25); wide + * enough to seat the shared [WeekNumberBadge] with a little breathing room. */ +private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp private val CELL_GAP = 2.dp @@ -562,10 +564,10 @@ 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. + * Left-gutter calendar-week indicator (#25), centred vertically in the row. Uses + * the shared [WeekNumberBadge] so it's identical to the Week view's, and computes + * the ISO week-of-week-based-year on the row's first day — the same basis — so the + * two views always agree. */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { @@ -573,23 +575,11 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier 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, + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier.height(DAY_NUMBER_HEIGHT), - contentAlignment = Alignment.Center, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + WeekNumberBadge(weekNumber = weekNumber) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 3af71f0..a2a59de 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -87,6 +87,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -507,26 +508,6 @@ private fun WeekDayHeader( } } -/** Calendar-week badge shown in the header gutter, deliberately set apart with a - * filled box and bold number. */ -@Composable -private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { - val label = stringResource(R.string.week_number_label) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} - @Composable private fun AllDayStrip( state: WeekUiState.Success, From 1f050c2be948b8bb71edb54ba1a17f4bc5d63518 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 22:24:11 +0200 Subject: [PATCH 3/3] feat: make Month week-number a full-height cell like the day cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per on-device review, render the week number as a full-height tonal pill mirroring the day cells' geometry (secondaryContainer tint, same rounded shape and gap), with the number centred — so the gutter reads as part of the grid rather than a floating chip. This diverges from the Week view's small header chip, so revert the shared-badge extraction: restore WeekScreen's private badge and drop ui/common/WeekNumberBadge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/common/WeekNumberBadge.kt | 38 ------------------- .../calendula/ui/month/MonthScreen.kt | 27 ++++++++----- .../calendula/ui/week/WeekScreen.kt | 21 +++++++++- 3 files changed, 38 insertions(+), 48 deletions(-) delete mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt deleted file mode 100644 index 771adf9..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/WeekNumberBadge.kt +++ /dev/null @@ -1,38 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.common - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import de.jeanlucmakiola.calendula.R - -/** - * Calendar-week badge — a filled tonal chip with a bold number, deliberately set - * apart from the surrounding day numbers. Shared by the Week and Month views so - * the week-of-year indicator reads identically wherever it appears. - */ -@Composable -fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { - val label = stringResource(R.string.week_number_label) - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, - ) { - Text( - text = weekNumber.toString(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f73a5f1..14bf928 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -73,7 +73,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -361,8 +360,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp -/** Width of the optional left gutter holding the calendar-week badge (#25); wide - * enough to seat the shared [WeekNumberBadge] with a little breathing room. */ +/** Width of the optional left calendar-week gutter (#25); narrow, since it only + * seats a one- or two-digit week number in a full-height tonal pill. */ private val WEEK_NUMBER_GUTTER = 40.dp private val DAY_NUMBER_GAP = 4.dp private val CELL_TOP_PADDING = 6.dp @@ -564,10 +563,11 @@ private fun MonthWeekRow( } /** - * Left-gutter calendar-week indicator (#25), centred vertically in the row. Uses - * the shared [WeekNumberBadge] so it's identical to the Week view's, and computes - * the ISO week-of-week-based-year on the row's first day — the same basis — so the - * two views always agree. + * Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the + * day cells' geometry, set apart by the secondaryContainer tint (matching the + * Week view's badge), with the ISO week number centred like a day number. The + * week is computed on the row's first day — the same basis as the Week view — so + * the two agree. */ @Composable private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) { @@ -575,11 +575,20 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier 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) Box( - modifier = modifier, + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE) + .semantics { contentDescription = "$label $weekNumber" }, contentAlignment = Alignment.Center, ) { - WeekNumberBadge(weekNumber = weekNumber) + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index a2a59de..3af71f0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -87,7 +87,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill -import de.jeanlucmakiola.calendula.ui.common.WeekNumberBadge import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion @@ -508,6 +507,26 @@ private fun WeekDayHeader( } } +/** Calendar-week badge shown in the header gutter, deliberately set apart with a + * filled box and bold number. */ +@Composable +private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { + val label = stringResource(R.string.week_number_label) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = modifier.semantics { contentDescription = "$label $weekNumber" }, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + @Composable private fun AllDayStrip( state: WeekUiState.Success,