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:
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("Берлін")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user