Merge pull request 'feat: week-of-year numbers in Month view (#25)' (!64) from feat/month-week-numbers into release/v2.14.0
Reviewed-on: #64
This commit was merged in pull request #64.
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,9 @@ 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 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 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 +372,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 +386,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 +408,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 +416,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
|
||||||
@@ -531,6 +559,37 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
@Composable
|
||||||
|
|||||||
@@ -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