fix(worldclock): fit the seconds readouts without touching the default

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.
This commit is contained in:
makiolaj committed 2026-10-21 19:32:44 +02:00
1 parent 7a86e5488e
commit 71875e95e4
7 files changed
+71 -59

No files matched your search

@@ -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.
@@ -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<Unit>
@@ -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<LocalTime> = 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)
}
}
@@ -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) },
@@ -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:
@@ -53,7 +53,7 @@ class FakeClockChanges : ClockChanges {
override fun changes(): Flow<Unit> = 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)
}
+16 -16
View File
@@ -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