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:
@@ -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")
|
||||||
|
|||||||
@@ -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,135 +415,184 @@ 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) {
|
||||||
val colW = maxWidth / 7
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// because they're measured inside the grid box, not the whole row.
|
||||||
// week/day views use, so the three views share one visual language.
|
if (showWeekNumbers) {
|
||||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
WeekNumberGutter(
|
||||||
// as one continuous event.
|
weekStart = week.days.first(),
|
||||||
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(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.weight(1f)
|
.fillMaxHeight(),
|
||||||
.clipToBounds(),
|
)
|
||||||
) {
|
}
|
||||||
// Spanning bars on their shared lanes.
|
BoxWithConstraints(
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
Modifier
|
||||||
val cols = span.endCol - span.startCol + 1
|
.weight(1f)
|
||||||
MonthBar(
|
.fillMaxHeight(),
|
||||||
event = span.event,
|
) {
|
||||||
dark = dark,
|
val colW = maxWidth / 7
|
||||||
continuesLeft = span.continuesLeft,
|
|
||||||
continuesRight = span.continuesRight,
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
modifier = Modifier
|
// week/day views use, so the three views share one visual language.
|
||||||
.offset(
|
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||||
x = colW * span.startCol,
|
// as one continuous event.
|
||||||
y = EVENT_ROW_HEIGHT * span.lane,
|
Row(Modifier.matchParentSize()) {
|
||||||
)
|
week.days.forEach { d ->
|
||||||
.width(colW * cols)
|
val inMonth = d.month == month.month && d.year == month.year
|
||||||
.height(EVENT_ROW_HEIGHT)
|
Box(
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
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
|
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||||
// sits on other days of the week.
|
Row(Modifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, d ->
|
week.days.forEach { d ->
|
||||||
val timed = week.timedByDay[d].orEmpty()
|
DayNumberCell(
|
||||||
val occupied = week.spans
|
date = d,
|
||||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
isToday = d == today,
|
||||||
.map { it.lane }
|
inMonth = d.month == month.month && d.year == month.year,
|
||||||
.toSet()
|
modifier = Modifier.weight(1f),
|
||||||
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
)
|
||||||
val pillsShown = timed.take(freeSlots.size)
|
}
|
||||||
pillsShown.forEachIndexed { i, ev ->
|
}
|
||||||
|
// 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(
|
MonthBar(
|
||||||
event = ev,
|
event = span.event,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = false,
|
continuesRight = span.continuesRight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * span.startCol,
|
||||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
y = EVENT_ROW_HEIGHT * span.lane,
|
||||||
)
|
)
|
||||||
.width(colW)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
// Single-day timed pills + overflow, per column. Pills fill the
|
||||||
if (hidden > 0) {
|
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||||
val hiddenColors = buildList {
|
// bar-free day isn't pushed down by a multi-day event that only
|
||||||
week.spans
|
// sits on other days of the week.
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
week.days.forEachIndexed { col, d ->
|
||||||
.forEach { add(it.event.color) }
|
val timed = week.timedByDay[d].orEmpty()
|
||||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
val occupied = week.spans
|
||||||
}.distinct().take(3)
|
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||||
OverflowDots(
|
.map { it.lane }
|
||||||
colors = hiddenColors,
|
.toSet()
|
||||||
extra = hidden - hiddenColors.size,
|
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||||
dark = dark,
|
val pillsShown = timed.take(freeSlots.size)
|
||||||
modifier = Modifier
|
pillsShown.forEachIndexed { i, ev ->
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
MonthBar(
|
||||||
.width(colW)
|
event = ev,
|
||||||
.padding(horizontal = 3.dp),
|
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
|
// 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.
|
// clipped to the background pill so the ripple matches it.
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEach { d ->
|
week.days.forEach { d ->
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable { onOpenDay(d) },
|
.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
|
@Composable
|
||||||
private fun DayNumberCell(
|
private fun DayNumberCell(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user