From 7a86e5488e92cdd64a1510c6a72b38425099d26f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 19:22:47 +0200 Subject: [PATCH] fix(worldclock): refresh on resume and size the row's time once With the tick a minute long, a language change left the city names in the old language until the next minute; the screen now sends a refresh on every resume. The row's time is sized once per format against a wide sample instead of searched for every second, and ellipsizes as a last resort rather than clipping. BackupSettings takes the setting without a default, so a snapshot cannot drop it silently. --- .../clockula/domain/backup/BackupContent.kt | 2 +- .../clockula/ui/worldclock/WorldClockRow.kt | 49 +++++++++++++------ .../ui/worldclock/WorldClockScreen.kt | 7 +++ .../ui/worldclock/WorldClockSource.kt | 16 +++--- .../ui/worldclock/WorldClockViewModel.kt | 16 +++++- .../clockula/data/backup/BackupCodecTest.kt | 1 + .../clockula/data/prefs/SettingsPrefsTest.kt | 1 + .../ui/worldclock/WorldClockViewModelTest.kt | 19 +++++++ docs/ARCHITECTURE.md | 6 ++- 9 files changed, 92 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index f4ac30e..d06af97 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -57,5 +57,5 @@ data class BackupSettings( val defaults: ClockDefaults, val timerPresets: List, /** The world clock's "Display time with seconds" (#23). */ - val worldClockSeconds: Boolean = false, + val worldClockSeconds: Boolean, ) 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 a3b2de5..2ece850 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 @@ -8,15 +8,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction @@ -24,10 +25,11 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics +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.sp import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel import de.jeanlucmakiola.clockula.domain.worldclock.OffsetLabel @@ -139,11 +141,14 @@ internal fun WorldClockRow( ) } if (time != null) { - val timeStyle = if (showSeconds) { - MaterialTheme.typography.headlineSmall - } else { - MaterialTheme.typography.headlineMedium - } + 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 @@ -153,19 +158,12 @@ internal fun WorldClockRow( style = timeStyle, maxLines = 1, softWrap = false, - // Capped, and shrunk to fit the cap, so at a large font - // scale the time cannot take the whole row and leave the - // city name no width at all. - autoSize = TextAutoSize.StepBased( - minFontSize = TimeMinFontSize, - maxFontSize = timeStyle.fontSize, - ), + 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) .clearAndSetSemantics {}, - fontWeight = FontWeight.Bold, color = if (isDragging) { MaterialTheme.colorScheme.primary } else { @@ -180,7 +178,26 @@ internal fun WorldClockRow( /** Wide enough for "12:59:59 PM" at the default scale; the city keeps the rest. */ private val TimeMaxWidth = 168.dp -private val TimeMinFontSize = 14.sp +/** 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) + +/** + * [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. + */ +@Composable +private fun fittedTimeStyle(base: TextStyle, formatTime: (LocalTime) -> String): TextStyle { + val measurer = rememberTextMeasurer(cacheSize = 0) + val density = LocalDensity.current + return remember(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)) + } +} /** * The offset, and the day only when it differs: "Same time as home" stands 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 7cfe2e7..0741c28 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 @@ -33,6 +33,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks @@ -68,6 +69,12 @@ fun WorldClockScreen( viewModel: WorldClockViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + // A language change recreates the activity and nothing else tells the + // tab: without this, city names would wait for the next minute's tick. + LifecycleResumeEffect(viewModel) { + viewModel.onResumed() + onPauseOrDispose {} + } val picker by viewModel.picker.collectAsStateWithLifecycle() val pillInset = LocalLivePillInset.current val loaded = state as? WorldClockUiState.Loaded 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 c0533b1..7144c81 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 @@ -15,9 +15,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import java.time.ZoneId import javax.inject.Inject @@ -40,8 +42,6 @@ internal class WorldClockSource @Inject constructor( private val storedHomeZoneId: Flow = settings.defaults.map { it.homeZoneId }.distinctUntilChanged() - private val showSeconds: Flow = settings.worldClockSeconds - /** * 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 @@ -50,7 +50,11 @@ internal class WorldClockSource @Inject constructor( * catches up after a deep sleep stretched a `delay`. 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. + * 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. * `SystemEventReceiver` still needs no new branch (D2). * * The tick is aligned to the wall clock and only as fine as the readouts: @@ -59,12 +63,12 @@ internal class WorldClockSource @Inject constructor( * state and `distinctUntilChanged` only drops the odd early wake (D18). */ @OptIn(ExperimentalCoroutinesApi::class) - fun state(): Flow = + fun state(refreshes: Flow = emptyFlow()): Flow = combine( worldClocks.worldClocks(), storedHomeZoneId, - showSeconds, - clockChanges.changes().onStart { emit(Unit) }, + settings.worldClockSeconds, + merge(clockChanges.changes(), refreshes).onStart { emit(Unit) }, ) { clocks, storedHome, seconds, _ -> Triple(clocks, storedHome, seconds) } .flatMapLatest { (clocks, storedHome, seconds) -> ticker.alignedTicks(WorldClockDefaults.tickFor(seconds)) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt index e997ac5..6f14b9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModel.kt @@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.domain.Zones import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -36,8 +38,11 @@ class WorldClockViewModel @Inject internal constructor( private val query = MutableStateFlow("") + /** One per screen resume; see [onResumed]. Buffered, so a send never suspends. */ + private val refreshes = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + /** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */ - internal val state: StateFlow = source.state() + internal val state: StateFlow = source.state(refreshes) // `WhileSubscribed`, not `Eagerly`: the upstream is a ticker and // eager sharing would keep it running with nobody looking. The timeout // is what gives rotation its resilience, as on every other tab. @@ -134,6 +139,15 @@ class WorldClockViewModel @Inject internal constructor( } } + /** + * The screen came back. Re-reads the clock, the zone and the names at once + * rather than at the next minute: a language change recreates the activity + * but not this ViewModel, and announces itself with no broadcast. + */ + fun onResumed() { + refreshes.tryEmit(Unit) + } + private companion object { /** The window a rotation has to re-subscribe in before the ticker is dropped. */ const val SUBSCRIPTION_GRACE_MS = 5_000L diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index c876048..4dca1df 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -85,6 +85,7 @@ class BackupCodecTest { dynamicColor = true, defaults = ClockDefaults(), timerPresets = emptyList(), + worldClockSeconds = false, ), ) val decoded = BackupCodec.decode(BackupCodec.encode(original)) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 71ead3e..c5b1d57 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -76,6 +76,7 @@ class SettingsPrefsTest { dynamicColor = false, defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "), timerPresets = listOf(5.minutes, 1.minutes, 5.minutes), + worldClockSeconds = false, ), ) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt index 247cca3..0666b93 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockViewModelTest.kt @@ -470,6 +470,25 @@ class WorldClockViewModelTest { harness.ticker.tick() advanceUntilIdle() + assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін") + } + + /** #23: with the tick a minute long, a resume is what brings a new language in. */ + @Test + fun `a resume renames the rows without waiting for a tick`(@TempDir tempDir: Path) = + runTest(main.dispatcher) { + val dao = FakeWorldClockDao( + listOf(WorldClockEntity(id = 1L, zoneId = "Europe/Berlin", label = null, sortOrder = 0)), + ) + val harness = collecting(worldClockHarness(tempDir, dao = dao)) + harness.names.cities = mapOf("Europe/Berlin" to "Berlin") + advanceUntilIdle() + + harness.names.localeTag = "uk" + harness.names.cities = mapOf("Europe/Berlin" to "Берлін") + harness.viewModel.onResumed() + advanceUntilIdle() + assertThat(harness.loaded().rows.single().name).isEqualTo("Берлін") } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7de204..acd72fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2473,7 +2473,11 @@ tick and would build every minute twice.) 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. +`distinctUntilChanged` drops when nothing moved. The screen also sends a +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. 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