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.
This commit is contained in:
makiolaj committed 2026-10-21 19:22:47 +02:00
1 parent adc5a3bdd6
commit 7a86e5488e
9 files changed
+92 -25

No files matched your search

@@ -57,5 +57,5 @@ data class BackupSettings(
val defaults: ClockDefaults,
val timerPresets: List<Duration>,
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean = false,
val worldClockSeconds: Boolean,
)
@@ -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
@@ -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
@@ -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<String?> =
settings.defaults.map { it.homeZoneId }.distinctUntilChanged()
private val showSeconds: Flow<Boolean> = 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<WorldClockUiState.Loaded> =
fun state(refreshes: Flow<Unit> = emptyFlow()): Flow<WorldClockUiState.Loaded> =
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))
@@ -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<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/** `WhileSubscribed(5_000)`, seeded [WorldClockUiState.Loading]. */
internal val state: StateFlow<WorldClockUiState> = source.state()
internal val state: StateFlow<WorldClockUiState> = 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
@@ -85,6 +85,7 @@ class BackupCodecTest {
dynamicColor = true,
defaults = ClockDefaults(),
timerPresets = emptyList(),
worldClockSeconds = false,
),
)
val decoded = BackupCodec.decode(BackupCodec.encode(original))
@@ -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,
),
)
@@ -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("Берлін")
}
}
+5 -1
View File
@@ -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