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