From 71875e95e42581ae07b7451f56143afd320f870a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:32:44 +0200 Subject: [PATCH] fix(worldclock): fit the seconds readouts without touching the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's time is fitted only with seconds on, against both day periods since an AM marker can be the wider one, and the style is built once by the screen rather than per row. With seconds off the row is exactly as before. The home readout stays on one line with seconds, shrinking to fit instead of wrapping. SCREEN_ON is dropped: the tab cannot be seen again without a resume, which already refreshes. §5 no longer claims a clock change heals within one tick for free. --- .../clockula/data/time/AndroidClockChanges.kt | 11 ++-- .../clockula/domain/time/Clocks.kt | 7 +-- .../clockula/ui/worldclock/WorldClockRow.kt | 58 ++++++++++--------- .../ui/worldclock/WorldClockScreen.kt | 12 +++- .../ui/worldclock/WorldClockSource.kt | 8 +-- .../clockula/testing/FakeTicker.kt | 2 +- docs/ARCHITECTURE.md | 32 +++++----- 7 files changed, 71 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt index 58c0e2e..82d49f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -15,12 +15,10 @@ import javax.inject.Inject import javax.inject.Singleton /** - * `TIME_SET`, `TIMEZONE_CHANGED` and `SCREEN_ON`, through a receiver that is - * registered only while somebody collects. The first two already have their - * manifest receiver (`SystemEventReceiver`) for the alarm engine; this one is - * for a readout. `SCREEN_ON` cannot be declared in the manifest at all, and is - * here because a `delay` on the main looper does not count deep sleep: a - * readout left subscribed through a sleep would otherwise wake stale. + * `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver that is registered + * only while somebody collects. Both already have their manifest receiver + * (`SystemEventReceiver`) for the alarm engine; this one is for a readout on + * screen, which must not wait out its cadence to show either. */ @Singleton class AndroidClockChanges @Inject constructor( @@ -41,7 +39,6 @@ class AndroidClockChanges @Inject constructor( val filter = IntentFilter().apply { addAction(Intent.ACTION_TIME_CHANGED) addAction(Intent.ACTION_TIMEZONE_CHANGED) - addAction(Intent.ACTION_SCREEN_ON) } // Protected system broadcasts still arrive at a non-exported receiver, // and nothing else on the device gets to poke the tab. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index f1d7e41..4bb48b0 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -52,10 +52,9 @@ interface Ticker { /** * The system saying the wall clock may not read what a readout last drew: the - * time was set, the zone changed, or the screen came back on after a sleep - * that a `delay` does not count. A readout on a long cadence restarts its tick - * on this, so it does not wait out its own period to catch up. Emits only - * while collected. + * time was set or the zone changed. A readout on a long cadence restarts its + * tick on this, so it does not wait out its own period to catch up. Emits + * only while collected. */ interface ClockChanges { fun changes(): Flow diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt index 2ece850..7bf6ef4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockRow.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel @@ -59,7 +61,7 @@ internal fun WorldClockRow( position: Position, formatTime: (LocalTime) -> String, formatSpokenTime: (LocalTime) -> String, - showSeconds: Boolean, + timeStyle: RowTimeStyle, dragHandle: Modifier, isDragging: Boolean, onMoveUp: () -> Unit, @@ -141,28 +143,18 @@ internal fun WorldClockRow( ) } if (time != null) { - val timeStyle = fittedTimeStyle( - base = if (showSeconds) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.headlineMedium - }, - formatTime = formatTime, - ) Text( text = time, // Tabular, because M7 made the headline role tabular // app-wide: a column of times must not jitter as it ticks. - // A size down with seconds, which add three or more glyphs - // the city name would otherwise pay for. - style = timeStyle, + style = timeStyle.style, maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis, // The row's description already says the time, to the // minute; the ticking text itself stays out of the tree. modifier = Modifier - .widthIn(max = TimeMaxWidth) + .widthIn(max = timeStyle.maxWidth) .clearAndSetSemantics {}, color = if (isDragging) { MaterialTheme.colorScheme.primary @@ -175,27 +167,41 @@ internal fun WorldClockRow( } } -/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ -private val TimeMaxWidth = 168.dp +/** How a row draws its time; the same for every row, so the screen builds it once. */ +@Immutable +internal data class RowTimeStyle(val style: TextStyle, val maxWidth: Dp) -/** A wide time in every format: two-digit hour, eights, and PM where there is one. */ -private val WidestSample: LocalTime = LocalTime.of(22, 58, 58) +/** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ +private val SecondsMaxWidth = 168.dp /** - * [base], bold, shrunk just enough that [WidestSample] fits [TimeMaxWidth], so - * at a large font scale the time cannot take the whole row and leave the city - * name no width at all. Measured once per format and scale rather than per - * tick: the digits are tabular, so every time is as wide as the sample. + * Wide times in every format: a two-digit hour, eights, and each day period, + * since a locale's AM marker can be wider than its PM one. + */ +private val WidestSamples: List = listOf(LocalTime.of(10, 58, 58), LocalTime.of(22, 58, 58)) + +/** + * Without seconds, the row's time exactly as it always was: `headlineMedium`, + * bold, uncapped. With seconds, a size down, and shrunk further only if the + * widest sample would not fit [SecondsMaxWidth], so at a large font scale the + * time cannot take the whole row and leave the city no width at all. + * Measured once per format, style and density, never per tick: the digits are + * tabular, so no time is wider than the samples. */ @Composable -private fun fittedTimeStyle(base: TextStyle, formatTime: (LocalTime) -> String): TextStyle { +internal fun rememberRowTimeStyle(showSeconds: Boolean, formatTime: (LocalTime) -> String): RowTimeStyle { + val base = if (showSeconds) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium val measurer = rememberTextMeasurer(cacheSize = 0) val density = LocalDensity.current - return remember(base, formatTime, density) { + return remember(showSeconds, base, formatTime, density) { val style = base.copy(fontWeight = FontWeight.Bold) - val width = measurer.measure(formatTime(WidestSample), style, maxLines = 1, softWrap = false).size.width - val cap = with(density) { TimeMaxWidth.toPx() } - if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + if (!showSeconds) return@remember RowTimeStyle(style, Dp.Infinity) + val width = WidestSamples.maxOf { sample -> + measurer.measure(formatTime(sample), style, maxLines = 1, softWrap = false).size.width + } + val cap = with(density) { SecondsMaxWidth.toPx() } + val fitted = if (width <= cap) style else style.copy(fontSize = style.fontSize * (cap / width)) + RowTimeStyle(fitted, SecondsMaxWidth) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index 0741c28..2fcba37 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Home @@ -81,6 +82,7 @@ fun WorldClockScreen( val formatTime = rememberLocalTimeFormatter(withSeconds = loaded?.showSeconds == true) // What TalkBack is given, always to the minute: see WorldClockRow. val formatSpokenTime = rememberLocalTimeFormatter() + val rowTimeStyle = rememberRowTimeStyle(showSeconds = loaded?.showSeconds == true, formatTime = formatTime) // Saveable, not remembered: a rotation mid-confirmation must not lose the // question it was asking (D13). @@ -151,7 +153,7 @@ fun WorldClockScreen( position = position, formatTime = formatTime, formatSpokenTime = formatSpokenTime, - showSeconds = loaded.showSeconds, + timeStyle = rowTimeStyle, dragHandle = dragHandle, isDragging = isDragging, onMoveUp = { viewModel.onMoveUp(row.id) }, @@ -246,6 +248,14 @@ private fun HomeFace( style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, + // With seconds, one line that shrinks to fit rather than a + // readout that wraps, and moves the list, as the hour widens. + maxLines = if (showSeconds) 1 else Int.MAX_VALUE, + autoSize = if (showSeconds) { + TextAutoSize.StepBased(maxFontSize = MaterialTheme.typography.displaySmall.fontSize) + } else { + null + }, modifier = Modifier .padding(bottom = 8.dp) .clearAndSetSemantics { text = AnnotatedString(spokenTime) }, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt index 7144c81..3a7ef95 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockSource.kt @@ -46,15 +46,15 @@ internal class WorldClockSource @Inject constructor( * The tab's state. Both the instant and the device zone are re-read on * **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and * `TIMEZONE_CHANGED` show at once rather than at the next minute, the next - * wait is measured against the clock as it now reads, and `SCREEN_ON` - * catches up after a deep sleep stretched a `delay`. It sits + * wait is measured against the clock as it now reads. It sits * outside the `flatMapLatest`, so its receiver is registered once per * subscription rather than once per list edit, and `onStart` lets the * first state through without waiting for a broadcast. [refreshes] does * the same for what no broadcast announces: the screen sends one on every * resume, which covers a per-app language change (the activity is - * recreated, the ViewModel and this flow are not) without waiting for the - * next minute. + * recreated, the ViewModel and this flow are not) and a `delay` stretched + * by a deep sleep (the main looper's clock stops while the device sleeps) + * without waiting for the next minute. * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt index b0f9dd3..bae45e4 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeTicker.kt @@ -53,7 +53,7 @@ class FakeClockChanges : ClockChanges { override fun changes(): Flow = stream - /** One `TIME_SET`/`TIMEZONE_CHANGED`/`SCREEN_ON` to every collector. */ + /** One `TIME_SET`/`TIMEZONE_CHANGED` to every collector. */ suspend fun fire() { stream.emit(Unit) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index acd72fc..14538da 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -579,9 +579,11 @@ Lap is refused rather than recorded below the last one. **M8 adds a wall-clock surface.** The world clock is a calendar fact about somewhere else, so it reads `WallClock.now()` and never `ElapsedRealtimeClock`, the same column of the table above that alarms sit in. **Both the instant and the -device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. That is what -makes `TIME_SET` and `TIMEZONE_CHANGED` free for this tab: it self-heals -within one tick, and `SystemEventReceiver` needs no new branch. The zone never +device zone are re-read on every tick**, from the injected `WallClock` and `ZoneProvider`. Since #23 the +tick is a minute long unless seconds are on, so `TIME_SET` and +`TIMEZONE_CHANGED` also restart it through `ClockChanges`, a receiver the tab +registers while it collects (§16, "The cadence"); `SystemEventReceiver` still +needs no new branch. The zone never comes from `ZoneId.systemDefault()` at a call site; a build rule (§8) fails on one anywhere under `domain/` or `ui/`. @@ -2460,24 +2462,22 @@ it switches the cadence at once through the same `flatMapLatest`. A minute is too long to wait for `TIME_SET` or `TIMEZONE_CHANGED`, which can arrive with the tab on screen (a network zone change on landing, split screen). So `ClockChanges` (`AndroidClockChanges`, a receiver registered only -while the tab collects) restarts the tick: those two, plus `SCREEN_ON`, make -the state re-read the clock and the zone at once, and the restarted ticker -measures its next wait against the clock as it now reads. On -`TIMEZONE_CHANGED` the receiver also resets the process's cached default -zone itself, since the system's own reset is a separate call that can land -after the broadcast. `SCREEN_ON` covers the one gap an aligned `delay` has, a -deep sleep inside the five-second `WhileSubscribed` grace, which stretches -the wait because the main looper's clock stops while the device sleeps. -(`TIME_TICK` would also do, but it fires on the same boundary as the minute -tick and would build every minute twice.) The signal is combined +while the tab collects) restarts the tick: those two make the state re-read +the clock and the zone at once, and the restarted ticker measures its next +wait against the clock as it now reads. On `TIMEZONE_CHANGED` the receiver +also resets the process's cached default zone itself, since the system's own +reset is a separate call that can land after the broadcast. The signal is combined with the stored list, the home zone and the setting *outside* the `flatMapLatest`, so the receiver is registered once per subscription, not once per list edit. Each broadcast costs one extra `build`, which `distinctUntilChanged` drops when nothing moved. The screen also sends a -refresh on every resume, merged into the same signal: a per-app language +refresh on every resume, merged into the same signal. A per-app language change recreates the activity but not the ViewModel and is announced by no -broadcast, and the city names (§16 above) would otherwise wait for the next -minute to change language. +broadcast, so the city names would otherwise wait for the next minute to +change language. And an aligned `delay` has one gap of its own, a deep sleep +inside the five-second `WhileSubscribed` grace, which stretches the wait +because the main looper's clock stops while the device sleeps; the tab +cannot be seen again without a resume, which re-reads the clock. TalkBack is given the time to the minute even with seconds on: the row's description and the home readout's semantics use the minute formatter, and