From 2da892e34ac4549f9f1c605faa43f13d9593fbf1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 6 Nov 2026 11:11:37 +0100 Subject: [PATCH] Month view: setting to hide start times, per-chip title rule (#476) (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Start times switch under Settings → Views → Month, and the time on a month chip now only shows when the whole title or its first 12 characters still fit beside it. --- CHANGELOG.md | 2 + .../calendula/data/prefs/SettingsPrefs.kt | 5 ++ .../calendula/ui/common/EventTimeStyle.kt | 29 ++----- .../calendula/ui/month/MonthChipTime.kt | 82 ++++++++++++++++++- .../calendula/ui/month/MonthScreen.kt | 67 +++++++++------ .../calendula/ui/month/MonthStylePreview.kt | 4 + .../calendula/ui/month/MonthViewModel.kt | 10 +++ .../ui/onboarding/OnboardingViewModel.kt | 2 + .../calendula/ui/onboarding/ViewStep.kt | 8 +- .../ui/settings/MonthViewStylePicker.kt | 2 + .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 3 + .../calendula/ui/settings/ViewsSettings.kt | 9 ++ .../calendula/ui/settings/WeekStartPicker.kt | 2 + app/src/main/res/values/strings.xml | 2 + .../calendula/data/prefs/SettingsPrefsTest.kt | 8 ++ .../calendula/ui/month/MonthChipTimeTest.kt | 36 ++++++++ 17 files changed, 221 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66722b8..0ce08c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]). - **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]). - **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]). +- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]). ### Changed - **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]). @@ -1920,3 +1921,4 @@ automatically, with zero telemetry and no internet permission. [#438]: https://codeberg.org/jlmakiola/calendula/issues/438 [#439]: https://codeberg.org/jlmakiola/calendula/issues/439 [#441]: https://codeberg.org/jlmakiola/calendula/issues/441 +[#476]: https://codeberg.org/jlmakiola/calendula/issues/476 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 3f6a630..793c01e 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 @@ -146,6 +146,7 @@ class SettingsPrefs @Inject constructor( val pastEventDisplay: Flow = prefs.flow(SettingsKeys.pastEventDisplay) val dimCompletedEvents: Flow = prefs.flow(SettingsKeys.dimCompletedEvents) val showWeekNumbers: Flow = prefs.flow(SettingsKeys.showWeekNumbers) + val monthShowStartTimes: Flow = prefs.flow(SettingsKeys.monthShowStartTimes) val monthViewStyle: Flow = prefs.flow(SettingsKeys.monthViewStyle) val timelineScale: Flow = prefs.flow(SettingsKeys.timelineScale) val multiDayCount: Flow = prefs.flow(SettingsKeys.multiDayCount) @@ -212,6 +213,7 @@ class SettingsPrefs @Inject constructor( suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode) suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled) suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled) + suspend fun setMonthShowStartTimes(enabled: Boolean) = prefs.set(SettingsKeys.monthShowStartTimes, enabled) suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style) suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale) suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count) @@ -445,6 +447,9 @@ object SettingsKeys { */ val showWeekNumbers: Pref = booleanPref("show_week_numbers", false) + /** Whether Month chips may show the start time before the title when wide enough (#219, #476). */ + val monthShowStartTimes: Pref = booleanPref("month_show_start_times", true) + /** How the Month view lays itself out (#38, #53). */ val monthViewStyle: Pref = enumPref("month_view_style", MonthViewStyle.Paged) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt index fdd38e9..3008179 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt @@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.floret.locale.currentLocale @@ -45,33 +42,17 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri append(title) } -/** - * The characters of title that have to survive a time prefix for it to be worth - * its place — lowercase Latin of average advance, priced at the surface's own - * text style rather than guessed in dp. - */ -private const val TITLE_SAMPLE = "notepad" - /** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */ private const val SAMPLE_HOUR = 12 private const val SAMPLE_MINUTE = 45 /** - * The narrowest run of [style] text that may carry a time in front of a title: - * wide enough for the widest time in the current convention plus [TITLE_SAMPLE]. - * - * Measured rather than a device breakpoint, so it follows the font scale, the - * 12/24-hour setting, the locale's own time format and whatever else has - * already been taken off the width. + * The widest time the current convention writes, for pricing a time prefix by + * measurement: it follows the 12/24-hour setting and the locale's own format. */ @Composable -fun rememberInlineTimeWidth(style: TextStyle): Dp { - val measurer = rememberTextMeasurer() - val density = LocalDensity.current +fun rememberWidestTimeSample(): String { val locale = currentLocale() - val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale) - return remember(sample, style, density, measurer) { - val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified) - with(density) { measurer.measure(label, style).size.width.toDp() } - } + val is24Hour = LocalUse24HourFormat.current + return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt index 065aa22..3ab373b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -2,11 +2,21 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay -import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth +import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel +import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import kotlinx.datetime.TimeZone import java.util.Locale @@ -26,7 +36,71 @@ internal fun monthChipTime( return formatTimeOfDay(event.start, zone, is24Hour, locale) } -/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ +/** How many characters of a longer title a chip must keep beside its start time (#476). */ +internal const val MONTH_TITLE_MIN_CHARS = 12 + +/** + * The part of [title] that has to stay readable beside a start time: all of it, + * or its first [MONTH_TITLE_MIN_CHARS] characters, counted in code points so an + * emoji is never cut in half. + */ +internal fun monthTitleToKeep(title: String): String { + if (title.codePointCount(0, title.length) <= MONTH_TITLE_MIN_CHARS) return title + return title.substring(0, title.offsetByCodePoints(0, MONTH_TITLE_MIN_CHARS)) +} + +/** + * Whether a chip shows its start time (#219, #476): only with the setting + * [enabled], and only when [availableWidth] (the chip less its chrome) holds + * [labelWidth], the widest time followed by [monthTitleToKeep] of the title. + */ +internal fun monthChipShowsTime( + enabled: Boolean, + availableWidth: Float, + labelWidth: Float, +): Boolean = enabled && labelWidth <= availableWidth + +/** + * Prices month chips' time labels for [monthChipShowsTime] in the chips' own + * text style. One per grid; each title is measured once for its lifetime. + */ +@Stable +internal class MonthChipTimeFit( + private val measurer: TextMeasurer, + private val style: TextStyle, + private val density: Density, + private val timeSample: String, +) { + private val chromePx = with(density) { MONTH_CHIP_CHROME.toPx() } + private val labelWidths = HashMap() + + fun showsTime(chipWidth: Dp, title: String): Boolean { + if (labelWidths.size >= MAX_CACHED_TITLES) labelWidths.clear() + val labelWidth = labelWidths.getOrPut(title) { + val label = inlineTimeLabel(timeSample, monthTitleToKeep(title), Color.Unspecified) + measurer.measure(label, style, softWrap = false, maxLines = 1).size.width + } + return monthChipShowsTime( + enabled = true, + availableWidth = with(density) { chipWidth.toPx() } - chromePx, + labelWidth = labelWidth.toFloat(), + ) + } + + private companion object { + const val MAX_CACHED_TITLES = 1000 + } +} + +/** The grid's [MonthChipTimeFit], or null when start times are switched off (#476). */ @Composable -internal fun rememberMonthTimeChipWidth(): Dp = - rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME +internal fun rememberMonthChipTimeFit(showStartTimes: Boolean): MonthChipTimeFit? { + if (!showStartTimes) return null + val measurer = rememberTextMeasurer(cacheSize = 0) + val style = MaterialTheme.typography.labelSmall.trimmedLines() + val density = LocalDensity.current + val timeSample = rememberWidestTimeSample() + return remember(measurer, style, density, timeSample) { + MonthChipTimeFit(measurer, style, density, timeSample) + } +} 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 e701038..807aeab 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 @@ -278,6 +278,7 @@ fun MonthScreen( val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle() + val showStartTimes by viewModel.showStartTimes.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). @@ -525,17 +526,20 @@ fun MonthScreen( ) { if (scrolling) { ContinuousMonthContent( - state = continuousState, + // Held on the skeleton until the start-time setting has + // loaded, so chips don't redraw without their times. + state = if (showStartTimes == null) ContinuousMonthUiState.Loading else continuousState, listState = listState, dense = dense, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes == true, onRetry = jumpToToday, onOpenDay = onOpenDay, onEventClick = onEventClick, ) } else { SplitMonthContent( - state = state, + state = if (showStartTimes == null) MonthUiState.Loading else state, selected = selectedDate, initiallyExpanded = viewStyle == MonthViewStyle.Paged, expanded = splitExpanded, @@ -544,6 +548,7 @@ fun MonthScreen( weekStart = weekStart, monthState = viewModel::month, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes == true, showToday = showToday, onPaneSwipeNext = { viewModel.stepSplitPane(1) }, onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, @@ -565,7 +570,7 @@ fun MonthScreen( } } } - MonthDragOverlay(chipDrag) + MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true) } } } @@ -578,11 +583,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L /** The chip in flight, drawn over the grid and following the finger. */ @Composable -private fun MonthDragOverlay(controller: MonthDragController) { +private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) { var origin by remember { mutableStateOf(Offset.Zero) } val dark = LocalFloretDarkTheme.current val density = LocalDensity.current - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) val reduceMotion = rememberReduceMotion() val moveInFlight = moveInFlight() // Here rather than beside the controller: this reads [drag], which changes @@ -644,7 +649,8 @@ private fun MonthDragOverlay(controller: MonthDragController) { // cut-out of it has the room — which a slice of a multi-day bar has // not, however wide the bar was. time = drag.time, - showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth, + timeFit = timeFit, + chipWidth = with(density) { drag.sizePx.width.toDp() }, modifier = Modifier // Absolute: these are root coordinates, and the direction-aware // offset would mirror them across the screen in an RTL layout. @@ -681,6 +687,7 @@ private fun ContinuousMonthContent( listState: LazyListState, dense: Boolean, showWeekNumbers: Boolean, + showStartTimes: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -697,6 +704,7 @@ private fun ContinuousMonthContent( state = state, listState = listState, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onOpenDay, onEventClick = onEventClick, ) @@ -705,6 +713,7 @@ private fun ContinuousMonthContent( state = state, listState = listState, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onOpenDay, onEventClick = onEventClick, ) @@ -877,6 +886,7 @@ private val DENSE_HEADER_GAP = 4.dp internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ @@ -901,13 +911,13 @@ internal fun MonthGrid( val month = state.month // Once per grid: the value depends on the typography, the density, the // locale and the 24-hour setting, none of which vary by row (#219). - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) state.weeks.forEach { week -> MonthWeekRow( week = week, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = rowHeight, inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, @@ -943,13 +953,14 @@ internal fun ContinuousMonthGrid( state: ContinuousMonthUiState.Success, listState: LazyListState, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val monthCount = remember { continuousMonthCount() } val todayMonth = remember(state.today) { state.today.yearMonth } - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) LazyColumn( state = listState, modifier = modifier.fillMaxSize(), @@ -972,7 +983,7 @@ internal fun ContinuousMonthGrid( weekStart = state.weekStart, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -994,7 +1005,7 @@ private fun ContinuousMonthBlock( weekStart: DayOfWeek, today: LocalDate, zone: TimeZone, - timeChipWidth: Dp, + timeFit: MonthChipTimeFit?, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -1015,7 +1026,7 @@ private fun ContinuousMonthBlock( week = week, today = today, zone = zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = CONTINUOUS_ROW_HEIGHT, inMonth = { it.month == month.month && it.year == month.year }, // The block owns its month alone: a day from either @@ -1081,12 +1092,13 @@ internal fun DenseMonthGrid( state: ContinuousMonthUiState.Success, listState: LazyListState, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) LazyColumn( state = listState, modifier = modifier @@ -1110,7 +1122,7 @@ internal fun DenseMonthGrid( week = week, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = CONTINUOUS_ROW_HEIGHT, // Every day in the stream belongs to a month equally — there // is no "other month" to recede here. @@ -1202,6 +1214,7 @@ private fun SplitMonthContent( weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, + showStartTimes: Boolean, showToday: Boolean, onPaneSwipeNext: () -> Unit, onPaneSwipePrev: () -> Unit, @@ -1315,6 +1328,7 @@ private fun SplitMonthContent( state = page, selected = pageSelected, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, expandDrag = expandDrag, onPickDay = onPickDay, // A tapped chip is asking for that event, not for its @@ -1495,6 +1509,7 @@ private fun SplitMonthExpanded( /** Marked nowhere here; it only anchors the outline's morph. */ selected: LocalDate?, showWeekNumbers: Boolean, + showStartTimes: Boolean, expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -1507,6 +1522,7 @@ private fun SplitMonthExpanded( MonthGrid( state = state, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onPickDay, onEventClick = onEventClick, selected = selected, @@ -2108,8 +2124,8 @@ private fun MonthWeekRow( today: LocalDate, /** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */ zone: TimeZone, - /** The narrowest chip that may carry a start time, measured once per grid (#219). */ - timeChipWidth: Dp, + /** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */ + timeFit: MonthChipTimeFit?, /** The height this row was given, which decides its lanes — see [laneCapFor]. */ rowHeight: Dp, inMonth: (LocalDate) -> Boolean, @@ -2235,8 +2251,8 @@ private fun MonthWeekRow( ), ), ) { - // What a chip has to spend, against the [timeChipWidth] a start time - // costs it (#219). + // What a chip has to spend, against what [timeFit] says its start + // time costs it (#219). val colW = maxWidth / 7 // Held here rather than read at the offset: the dots are placed // inside a plain lambda, which is no longer in this scope. The box @@ -2326,7 +2342,8 @@ private fun MonthWeekRow( continuesRight = span.continuesRight, days = week.days.subList(span.startCol, span.endCol + 1), time = chipTimes[span.event.instanceId], - showTime = colW * cols >= timeChipWidth, + timeFit = timeFit, + chipWidth = colW * cols, modifier = Modifier .offset( x = colW * span.startCol, @@ -2403,7 +2420,8 @@ private fun MonthWeekRow( continuesRight = false, days = listOf(d), time = chipTimes[ev.instanceId], - showTime = colW >= timeChipWidth, + timeFit = timeFit, + chipWidth = colW, modifier = Modifier .offset( x = colW * col, @@ -2729,7 +2747,7 @@ private fun shortMonthName(date: LocalDate): String { /** * A filled event pill/bar — softened (or raw) fill, title clipped to one line, - * with the start time before it where the chip is wide enough ([showTime], #219). + * with the start time before it where the chip is wide enough ([timeFit], #219, #476). */ @Composable private fun MonthBar( @@ -2746,10 +2764,13 @@ private fun MonthBar( days: List? = null, /** The chip's start time, or null where it has none — see `monthChipTime` (#219). */ time: String? = null, - /** Whether this chip has the width to draw [time]; a screen reader gets it either way. */ - showTime: Boolean = false, + /** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */ + timeFit: MonthChipTimeFit? = null, + /** The chip's full width, chrome included. */ + chipWidth: Dp = 0.dp, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title) val dimCutoff = LocalDimCutoff.current // A bar is one piece, so it dims once the last day it covers has passed. val dimmed = dimCutoff != null && diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index f27dcc6..b090c28 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -31,6 +31,7 @@ import kotlinx.datetime.yearMonth internal fun MonthStylePreview( style: MonthViewStyle, weekStart: DayOfWeek, + showStartTimes: Boolean, height: Dp, modifier: Modifier = Modifier, ) { @@ -44,6 +45,7 @@ internal fun MonthStylePreview( MonthViewStyle.Paged -> MonthGrid( state = sample.month, showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) @@ -55,6 +57,7 @@ internal fun MonthStylePreview( ), ), showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) @@ -67,6 +70,7 @@ internal fun MonthStylePreview( initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1, ), showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) 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 39e2233..8f83b13 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 @@ -92,6 +92,16 @@ class MonthViewModel @Inject constructor( initialValue = true, ) + /** + * Whether chips may show their start time before the title (#476; display + * only). Null until the setting has loaded, so the grid can wait for it. + */ + val showStartTimes: StateFlow = settingsPrefs.monthShowStartTimes + .stateWhileSubscribed( + scope = viewModelScope, + initialValue = null, + ) + /** How the grid is laid out and navigated (#38, #53). */ val viewStyle: StateFlow = settingsPrefs.monthViewStyle .stateWhileSubscribed( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 9407aa9..2a2894d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -220,6 +220,7 @@ class OnboardingViewModel @Inject constructor( prefs.defaultView, prefs.monthViewStyle, prefs.weekStart, + prefs.monthShowStartTimes, ::ViewChoice, ).stateWhileSubscribed( scope = viewModelScope, @@ -464,6 +465,7 @@ data class ViewChoice( val defaultView: CalendarView, val monthViewStyle: MonthViewStyle, val weekStart: WeekStartPref, + val monthShowStartTimes: Boolean, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index 1d512e2..e955340 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -63,6 +63,7 @@ internal fun ViewStep( CalendarView.Month -> MonthStylePreview( style = choice.monthViewStyle, weekStart = weekStart, + showStartTimes = choice.monthShowStartTimes, height = PREVIEW_HEIGHT, ) CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT) @@ -118,7 +119,12 @@ internal fun MonthStyleStep( navigationIcon = navigationIcon, ) { StepPreview(selected = choice.monthViewStyle) { style -> - MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT) + MonthStylePreview( + style = style, + weekStart = weekStart, + showStartTimes = choice.monthShowStartTimes, + height = PREVIEW_HEIGHT, + ) } GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp) options.forEachIndexed { index, style -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index c780a73..cac3d08 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -33,6 +33,7 @@ import kotlinx.datetime.DayOfWeek internal fun MonthViewStylePicker( selected: MonthViewStyle, weekStart: DayOfWeek, + showStartTimes: Boolean, onSelect: (MonthViewStyle) -> Unit, onDismiss: () -> Unit, ) { @@ -50,6 +51,7 @@ internal fun MonthViewStylePicker( MonthStylePreview( style = shown, weekStart = weekStart, + showStartTimes = showStartTimes, height = PREVIEW_HEIGHT, ) } 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 53c14bb..d97e7a0 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 @@ -42,6 +42,8 @@ data class SettingsUiState( val dimCompletedEvents: Boolean = false, /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ val showWeekNumbers: Boolean = false, + /** Whether Month chips may show start times before their titles (#476). */ + val monthShowStartTimes: Boolean = true, /** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */ val todayButtonInToolbar: Boolean = false, /** Whether events can be dragged to another slot to reschedule them (#68, #173). */ 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 8326032..812910d 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 @@ -101,6 +101,7 @@ class SettingsViewModel @Inject constructor( pastEventDisplay = this[SettingsKeys.pastEventDisplay], dimCompletedEvents = this[SettingsKeys.dimCompletedEvents], showWeekNumbers = this[SettingsKeys.showWeekNumbers], + monthShowStartTimes = this[SettingsKeys.monthShowStartTimes], todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar], dragToReschedule = this[SettingsKeys.dragToReschedule], tapOpensEditor = this[SettingsKeys.tapOpensEditor], @@ -292,6 +293,8 @@ class SettingsViewModel @Inject constructor( fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) } + fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) } + fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) } fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index b214efc..d6b8eab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -160,6 +160,13 @@ internal fun ViewsScreen( checked = state.showWeekNumbers, onCheckedChange = viewModel::setShowWeekNumbers, summary = stringResource(R.string.settings_week_numbers_summary), + position = Position.Middle, + ) + GroupedSwitchRow( + title = stringResource(R.string.settings_month_start_times), + checked = state.monthShowStartTimes, + onCheckedChange = viewModel::setMonthShowStartTimes, + summary = stringResource(R.string.settings_month_start_times_summary), position = Position.Bottom, ) @@ -296,6 +303,7 @@ internal fun ViewsScreen( selected = state.monthViewStyle, // The preview is a real grid, so it uses the real week start too. weekStart = state.weekStart.resolveFirstDay(currentLocale()), + showStartTimes = state.monthShowStartTimes, onSelect = viewModel::setMonthViewStyle, onDismiss = { showMonthStyle = false }, ) @@ -324,6 +332,7 @@ internal fun ViewsScreen( selected = state.weekStart, // The preview is a real grid, so it uses the user's own month style. monthStyle = state.monthViewStyle, + showStartTimes = state.monthShowStartTimes, options = WEEK_START_OPTIONS, onSelect = viewModel::setWeekStart, onDismiss = { showWeekStart = false }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt index a7682fb..0f1888c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -27,6 +27,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale internal fun WeekStartPicker( selected: WeekStartPref, monthStyle: MonthViewStyle, + showStartTimes: Boolean, options: List, onSelect: (WeekStartPref) -> Unit, onDismiss: () -> Unit, @@ -46,6 +47,7 @@ internal fun WeekStartPicker( MonthStylePreview( style = monthStyle, weekStart = day, + showStartTimes = showStartTimes, height = PREVIEW_HEIGHT, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 723f091..c5b8f79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -578,6 +578,8 @@ Currently %1$s Week numbers Show calendar-week numbers in month view + Start times + Show the start time before an event\'s title when there is room Today button in toolbar Show a jump-to-today button in the toolbar instead of a floating button App name 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 74a425b..d2fb8e6 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 @@ -156,6 +156,14 @@ class SettingsPrefsTest { assertThat(prefs.showWeekNumbers.first()).isTrue() } + @Test + fun `month start times default on and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = testSettings(tempDir) + assertThat(prefs.monthShowStartTimes.first()).isTrue() + prefs.setMonthShowStartTimes(false) + assertThat(prefs.monthShowStartTimes.first()).isFalse() + } + @Test fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt index ee272b2..14f16db 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -47,4 +47,40 @@ class MonthChipTimeTest { val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale) assertThat(time).isNull() } + + @Test + fun `switched off, no chip shows a time however wide`() { + assertThat(monthChipShowsTime(enabled = false, availableWidth = 1000f, labelWidth = 10f)).isFalse() + } + + @Test + fun `the time shows only while the label fits beside it`() { + assertThat(monthChipShowsTime(enabled = true, availableWidth = 80f, labelWidth = 80f)).isTrue() + assertThat(monthChipShowsTime(enabled = true, availableWidth = 79f, labelWidth = 80f)).isFalse() + } + + @Test + fun `a short title has to fit whole`() { + assertThat(monthTitleToKeep("Lunch")).isEqualTo("Lunch") + assertThat(monthTitleToKeep("Appointments")).isEqualTo("Appointments") + } + + @Test + fun `a long title only has to keep its first twelve characters`() { + assertThat(monthTitleToKeep("Quarterly planning review")).isEqualTo("Quarterly pl") + } + + @Test + fun `an emoji at the cut is kept whole`() { + val title = "Birthday 🎂 party at home" + val kept = monthTitleToKeep(title) + assertThat(kept).isEqualTo("Birthday 🎂 p") + assertThat(Character.isHighSurrogate(kept.last())).isFalse() + } + + @Test + fun `an emoji as the twelfth character is not split`() { + val kept = monthTitleToKeep("Team dinner🍕 tonight") + assertThat(kept).isEqualTo("Team dinner🍕") + } }