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..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 @@ -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,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) { private val EVENT_ROW_HEIGHT = 20.dp private val DAY_NUMBER_HEIGHT = 22.dp +/** 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 private val CELL_GAP = 2.dp @@ -363,6 +372,7 @@ private const val MAX_EVENT_ROWS = 3 @Composable private fun MonthGrid( state: MonthUiState.Success, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, ) { Column( @@ -376,6 +386,7 @@ private fun MonthGrid( week = week, today = state.today, month = state.month, + showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, modifier = Modifier .fillMaxWidth() @@ -397,6 +408,7 @@ private fun MonthWeekRow( week: MonthWeek, today: LocalDate, month: YearMonth, + showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { @@ -404,135 +416,182 @@ 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 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) { + 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) + Box( + modifier = modifier + .padding(horizontal = CELL_GAP, vertical = 1.dp) + .background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE) + .semantics { contentDescription = "$label $weekNumber" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = weekNumber.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + @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))