Merge branch 'feat/23-world-clock-seconds' into release/v1.0.0
# Conflicts: # app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt # app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt # docs/ARCHITECTURE.md # docs/BACKUP.md
This commit is contained in:
commit
fd7f5e8dcd
40 files changed
+860
-110
No files matched your search
+2
-1
@@ -65,7 +65,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
device's time-zone database, searchable in your language. Each city shows
|
||||
the offset from your time and whether it's a different day there. Reorder
|
||||
them by dragging or with a screen reader, and set a home time zone or follow
|
||||
the device.
|
||||
the device. "Display time with seconds" in Settings > Clock adds seconds to
|
||||
every time on the tab and a second hand to the face.
|
||||
- A live pill that shows whatever is counting from every tab.
|
||||
- Swipe between tabs on phones. Tablets and foldables get a side rail.
|
||||
- Other apps can set, show, snooze and dismiss alarms and timers through
|
||||
|
||||
@@ -115,6 +115,7 @@ class BackupEngine @Inject constructor(
|
||||
dynamicColor = settings.dynamicColor.first(),
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
worldClockSeconds = settings.worldClockSeconds.first(),
|
||||
bedtime = settings.currentBedtime(),
|
||||
)
|
||||
}
|
||||
@@ -143,6 +143,7 @@ object BackupCodec {
|
||||
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
|
||||
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
|
||||
volumeButtons = defaults.volumeButtons.name,
|
||||
worldClockSeconds = worldClockSeconds,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
)
|
||||
@@ -301,6 +302,7 @@ object BackupCodec {
|
||||
volumeButtons = settingsDto.volumeButtons.toEnum(VolumeButtonAction.CONTROL_VOLUME),
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
worldClockSeconds = settingsDto.worldClockSeconds,
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ data class SettingsDto(
|
||||
*/
|
||||
@SerialName("default_alarm_silence_after_minutes") val alarmSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
@SerialName("default_timer_silence_after_minutes") val timerSilenceAfterMinutes: Int = SilenceAfter.DEFAULT_MINUTES,
|
||||
/** Absent in files from before it existed, which keeps the default: off. */
|
||||
@SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false,
|
||||
/**
|
||||
* Absent is not empty: an empty list is a user who removed every preset,
|
||||
* while a file without the field (an older or hand-written one) keeps the
|
||||
|
||||
@@ -5,11 +5,13 @@ import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
|
||||
import de.jeanlucmakiola.clockula.data.time.RealTicker
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
@@ -39,4 +41,7 @@ abstract class TimeModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindTicker(impl: RealTicker): Ticker
|
||||
|
||||
@Binds
|
||||
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
|
||||
}
|
||||
@@ -66,6 +66,14 @@ object ClockPrefs {
|
||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
||||
|
||||
/**
|
||||
* The world clock tab's readouts to the second, with a second hand on the
|
||||
* face (#23). Off by default: it is what makes the tab wake every second
|
||||
* rather than every minute. Not part of `ClockDefaults`, which alarms and
|
||||
* timers inherit from, so flipping it re-emits nothing they watch.
|
||||
*/
|
||||
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
|
||||
|
||||
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
|
||||
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.alarmSilenceAfterMinutes] = backup.defaults.alarmSilenceAfterMinutes
|
||||
prefs[ClockPrefs.timerSilenceAfterMinutes] = backup.defaults.timerSilenceAfterMinutes
|
||||
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
|
||||
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
@@ -168,6 +169,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
|
||||
suspend fun setHomeZoneId(zoneId: String?) = store.set(ClockPrefs.homeZoneId, zoneId)
|
||||
|
||||
// --- world clock display (#23) ---
|
||||
|
||||
/** Its own flow, distinct-until-changed, so the clock defaults' watchers never see it move. */
|
||||
val worldClockSeconds: Flow<Boolean> = store.flow(ClockPrefs.worldClockSeconds)
|
||||
|
||||
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
|
||||
|
||||
// --- timer presets (M6) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* `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(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : ClockChanges {
|
||||
|
||||
override fun changes(): Flow<Unit> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
}
|
||||
// Protected system broadcasts still arrive at a non-exported receiver,
|
||||
// and nothing else on the device gets to poke the tab.
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallBoundaries
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -10,7 +12,7 @@ import javax.inject.Singleton
|
||||
|
||||
/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */
|
||||
@Singleton
|
||||
class RealTicker @Inject constructor() : Ticker {
|
||||
class RealTicker @Inject constructor(private val wallClock: WallClock) : Ticker {
|
||||
|
||||
/**
|
||||
* Cold, and per-collector: two readouts on different cadences get two
|
||||
@@ -24,4 +26,18 @@ class RealTicker @Inject constructor() : Ticker {
|
||||
delay(period)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold and per-collector, as [ticks] is. The wait is re-measured against
|
||||
* the wall clock after every emission rather than added up, so a `delay`
|
||||
* that ran a little long does not accumulate. A wall clock set mid-wait is
|
||||
* only caught up after that wait ends; a caller that cannot wait that long
|
||||
* restarts the flow on `ClockChanges`, as the world clock does.
|
||||
*/
|
||||
override fun alignedTicks(unit: Duration): Flow<Unit> = flow {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(WallBoundaries.untilNext(wallClock.now(), unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,8 @@ class ZoneDirectory @Inject constructor(
|
||||
|
||||
/**
|
||||
* One display name per id in [zoneIds], on the same memo. Batched for the
|
||||
* same reason [entriesFor] is: the tab asks five times a second, and a
|
||||
* dispatch and a lock per row would be a hundred and twenty-five of each.
|
||||
* same reason [entriesFor] is: the tab asks on every tick (once a second with
|
||||
* seconds on), and a dispatch and a lock per row would be twenty-five of each.
|
||||
*/
|
||||
suspend fun displayNamesOf(zoneIds: List<String>, at: Instant): Map<String, String?> =
|
||||
withContext(io) {
|
||||
|
||||
@@ -17,8 +17,8 @@ object Zones {
|
||||
|
||||
/**
|
||||
* Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry
|
||||
* `HashSet` on every call, and the world clock asks per row per 200 ms tick
|
||||
* — twenty-four cities would be a hundred and twenty-five throwaway sets a
|
||||
* `HashSet` on every call, and the world clock asks per row per tick —
|
||||
* with seconds on, twenty-four cities would be twenty-five throwaway sets a
|
||||
* second. The device's tzdata arrives as an APEX module that takes effect
|
||||
* at reboot, so the answer cannot change under a running process.
|
||||
*/
|
||||
|
||||
@@ -61,6 +61,8 @@ data class BackupSettings(
|
||||
val dynamicColor: Boolean,
|
||||
val defaults: ClockDefaults,
|
||||
val timerPresets: List<Duration>,
|
||||
/** The world clock's "Display time with seconds" (#23). */
|
||||
val worldClockSeconds: Boolean,
|
||||
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
|
||||
val bedtime: BedtimeSettings? = null,
|
||||
)
|
||||
@@ -40,4 +40,22 @@ interface ZoneProvider {
|
||||
*/
|
||||
interface Ticker {
|
||||
fun ticks(period: Duration): Flow<Unit>
|
||||
|
||||
/**
|
||||
* Emits once immediately, then just after every wall-clock instant that is
|
||||
* a whole multiple of [unit] ([WallBoundaries.untilNext]). For a readout of
|
||||
* whole seconds or minutes: it wakes exactly when the readout changes and
|
||||
* not otherwise, so it is both on time and as rare as it can be.
|
||||
*/
|
||||
fun alignedTicks(unit: Duration): Flow<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* The system saying the wall clock may not read what a readout last drew: the
|
||||
* 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>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Where the next whole second (or minute) of the wall clock falls. A readout
|
||||
* that shows whole units changes exactly at these instants, so waking there
|
||||
* rather than on a free-running cadence is what keeps it on time without
|
||||
* waking more often than it changes.
|
||||
*/
|
||||
object WallBoundaries {
|
||||
|
||||
/**
|
||||
* How long from [now] until the next instant that is a whole multiple of
|
||||
* [unit] since the epoch. Always in `(0, unit]`: standing exactly on a
|
||||
* boundary waits for the *next* one, so a loop over this never spins.
|
||||
*
|
||||
* Every zone in use today is offset from UTC by whole minutes, so a
|
||||
* boundary of the epoch is a boundary of every zone's local second and
|
||||
* minute alike.
|
||||
*/
|
||||
fun untilNext(now: Instant, unit: Duration): Duration {
|
||||
val unitMillis = unit.inWholeMilliseconds
|
||||
require(unitMillis > 0) { "unit must be at least a millisecond: $unit" }
|
||||
val into = Math.floorMod(now.toEpochMilliseconds(), unitMillis)
|
||||
return (unitMillis - into).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -16,20 +16,24 @@ import java.time.format.FormatStyle
|
||||
* alarm list, the editor and the world clock all read a time through it, so a
|
||||
* second `DateFormat.is24HourFormat` call site would be the start of two
|
||||
* answers (M5, M8 D17).
|
||||
*
|
||||
* [withSeconds] is the world clock's "show seconds" (#23): the same two-step,
|
||||
* one unit finer — `HH:mm:ss`, or the locale's medium form, which is its short
|
||||
* form with the seconds in the place that locale puts them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberLocalTimeFormatter(): (LocalTime) -> String {
|
||||
internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTime) -> String {
|
||||
val context = LocalContext.current
|
||||
// The locale can change under a running activity, and the 12/24-hour
|
||||
// choice rides the same configuration, so both are re-read with it.
|
||||
val configuration = LocalConfiguration.current
|
||||
val formatter = remember(configuration) {
|
||||
val formatter = remember(configuration, withSeconds) {
|
||||
// The device's 12/24-hour choice, then the locale's own short form —
|
||||
// the same two-step the ring notification already formats with.
|
||||
if (DateFormat.is24HourFormat(context)) {
|
||||
DateTimeFormatter.ofPattern("HH:mm")
|
||||
DateTimeFormatter.ofPattern(if (withSeconds) "HH:mm:ss" else "HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT)
|
||||
}
|
||||
}
|
||||
return remember(formatter) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -11,7 +12,10 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */
|
||||
/**
|
||||
* The clock tab's settings: the home zone, and whether the tab shows seconds.
|
||||
* The zone picker itself is ViewModel state, drawn by the hub.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClockSettingsScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
@@ -25,8 +29,20 @@ internal fun ClockSettingsScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_home_zone),
|
||||
summary = state.defaults.homeZoneId ?: stringResource(R.string.zone_picker_device_zone),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
onClick = viewModel::onOpenHomeZonePicker,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_world_clock_seconds),
|
||||
summary = stringResource(R.string.settings_world_clock_seconds_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.worldClockSeconds,
|
||||
onCheckedChange = viewModel::onWorldClockSecondsChanged,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.onWorldClockSecondsChanged(!state.worldClockSeconds) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ internal sealed interface SettingsUiState {
|
||||
val appearance: Appearance,
|
||||
val defaults: ClockDefaults,
|
||||
val selfCheckProblemCount: Int? = null,
|
||||
/** The world clock's "Display time with seconds" (#23); not a clock default. */
|
||||
val worldClockSeconds: Boolean,
|
||||
) : SettingsUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,9 @@ class SettingsViewModel @Inject internal constructor(
|
||||
settings.appearance,
|
||||
settings.defaults,
|
||||
selfCheckProblemCount,
|
||||
) { appearance, defaults, problemCount ->
|
||||
SettingsUiState.Loaded(appearance, defaults, problemCount)
|
||||
settings.worldClockSeconds,
|
||||
) { appearance, defaults, problemCount, worldClockSeconds ->
|
||||
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
||||
|
||||
/**
|
||||
@@ -249,6 +250,9 @@ class SettingsViewModel @Inject internal constructor(
|
||||
DefaultRingtoneKind.TIMER -> timerRingtoneUri
|
||||
}
|
||||
|
||||
fun onWorldClockSecondsChanged(enabled: Boolean) =
|
||||
viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
|
||||
|
||||
// --- the home time-zone picker ---
|
||||
|
||||
fun onOpenHomeZonePicker() {
|
||||
|
||||
@@ -2,12 +2,23 @@ package de.jeanlucmakiola.clockula.ui.worldclock
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.toKotlinDuration
|
||||
|
||||
internal object WorldClockDefaults {
|
||||
/** 200 ms — the second hand is never a fifth of a second behind (D18). */
|
||||
val Tick: Duration = 200.milliseconds
|
||||
/**
|
||||
* The unit every time on the tab is truncated to: the minute, or the second
|
||||
* only when the user asked for them (D18, #23).
|
||||
*/
|
||||
fun unitFor(showSeconds: Boolean): ChronoUnit = if (showSeconds) ChronoUnit.SECONDS else ChronoUnit.MINUTES
|
||||
|
||||
/**
|
||||
* The tab's cadence, aligned to the wall clock through
|
||||
* `Ticker.alignedTicks` so the readouts change on the boundary itself.
|
||||
* Derived from [unitFor], so the readouts and the cadence cannot drift apart.
|
||||
*/
|
||||
fun tickFor(showSeconds: Boolean): Duration = unitFor(showSeconds).duration.toKotlinDuration()
|
||||
|
||||
val HeroFaceSize: Dp = 200.dp
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ import java.time.LocalTime
|
||||
|
||||
/**
|
||||
* The app's one deliberate showpiece: a `MaterialShapes.Circle`↔`Sunny` dial
|
||||
* whose morph progress is [dayFraction], twelve hour marks and three hands.
|
||||
* whose morph progress is [dayFraction], twelve hour marks and two hands, or
|
||||
* three with [showSecondHand] — the world clock's "show seconds" (#23). Without
|
||||
* it the tab ticks once a minute, and a second hand there would stand still.
|
||||
*
|
||||
* The morph carries **information**, which is the whole reason it is here
|
||||
* (`docs/PLAN.md` §8, D5): a plain circle in the middle of the night there, a
|
||||
@@ -48,6 +50,7 @@ import java.time.LocalTime
|
||||
internal fun WorldClockFace(
|
||||
time: LocalTime,
|
||||
dayFraction: Float,
|
||||
showSecondHand: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dialColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -62,9 +65,9 @@ internal fun WorldClockFace(
|
||||
val dialPath = remember { Path() }
|
||||
val dialMatrix = remember { Matrix() }
|
||||
|
||||
// Reduce motion touches the morph, not the hands: the second hand is a
|
||||
// discrete position change carrying information, and a clock whose second
|
||||
// hand stops is a broken clock (D19).
|
||||
// Reduce motion touches the morph, not the hands: the second hand, when
|
||||
// shown, is a discrete position change carrying information, and a clock
|
||||
// whose second hand stops is a broken clock (D19).
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = dayFraction,
|
||||
animationSpec = if (reduceMotion) {
|
||||
@@ -82,7 +85,7 @@ internal fun WorldClockFace(
|
||||
) {
|
||||
drawDial(morph, progress, dialPath, dialMatrix, dialColor)
|
||||
drawTicks(markColor)
|
||||
drawHands(time, markColor, accentColor)
|
||||
drawHands(time, showSecondHand, markColor, accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +130,13 @@ private fun DrawScope.drawTicks(color: Color) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) {
|
||||
private fun DrawScope.drawHands(time: LocalTime, showSecondHand: Boolean, handColor: Color, accentColor: Color) {
|
||||
val radius = size.minDimension / 2f
|
||||
hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor)
|
||||
hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor)
|
||||
hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
|
||||
if (showSecondHand) {
|
||||
hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
|
||||
}
|
||||
drawCircle(accentColor, radius = 4.dp.toPx(), center = center)
|
||||
drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx()))
|
||||
}
|
||||
|
||||
@@ -7,22 +7,31 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.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.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
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.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.DayLabel
|
||||
@@ -52,6 +61,8 @@ internal fun WorldClockRow(
|
||||
row: WorldClockRowState,
|
||||
position: Position,
|
||||
formatTime: (LocalTime) -> String,
|
||||
formatSpokenTime: (LocalTime) -> String,
|
||||
timeStyle: RowTimeStyle,
|
||||
dragHandle: Modifier,
|
||||
isDragging: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
@@ -63,26 +74,36 @@ internal fun WorldClockRow(
|
||||
val time = row.time?.let(formatTime)
|
||||
// One sentence for TalkBack, in the order the row is read aloud in. A row
|
||||
// whose zone is gone has no time, and its summary already *is* the
|
||||
// unknown-zone sentence, so naming it twice would be a stutter.
|
||||
val description = if (time != null) {
|
||||
stringResource(R.string.world_clock_row_description, row.name, time, summary)
|
||||
// unknown-zone sentence, so naming it twice would be a stutter. Spoken to
|
||||
// the minute even with seconds on: a description that changed every
|
||||
// second would be re-announced every second while the row had focus.
|
||||
val spokenTime = row.time?.let(formatSpokenTime)
|
||||
val description = if (spokenTime != null) {
|
||||
stringResource(R.string.world_clock_row_description, row.name, spokenTime, summary)
|
||||
} else {
|
||||
stringResource(R.string.world_clock_row_description_unknown, row.name, summary)
|
||||
}
|
||||
val actions = listOf(
|
||||
CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_up)) {
|
||||
onMoveUp()
|
||||
true
|
||||
},
|
||||
CustomAccessibilityAction(stringResource(R.string.world_clock_row_move_down)) {
|
||||
onMoveDown()
|
||||
true
|
||||
},
|
||||
CustomAccessibilityAction(stringResource(R.string.world_clock_row_remove)) {
|
||||
onRemove()
|
||||
true
|
||||
},
|
||||
)
|
||||
val moveUpLabel = stringResource(R.string.world_clock_row_move_up)
|
||||
val moveDownLabel = stringResource(R.string.world_clock_row_move_down)
|
||||
val removeLabel = stringResource(R.string.world_clock_row_remove)
|
||||
// Remembered, so a row that ticks every second keeps equal actions and its
|
||||
// semantics only change when what TalkBack would say changes.
|
||||
val actions = remember(moveUpLabel, moveDownLabel, removeLabel, onMoveUp, onMoveDown, onRemove) {
|
||||
listOf(
|
||||
CustomAccessibilityAction(moveUpLabel) {
|
||||
onMoveUp()
|
||||
true
|
||||
},
|
||||
CustomAccessibilityAction(moveDownLabel) {
|
||||
onMoveDown()
|
||||
true
|
||||
},
|
||||
CustomAccessibilityAction(removeLabel) {
|
||||
onRemove()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
GroupedSurface(
|
||||
position = position,
|
||||
@@ -134,8 +155,17 @@ internal fun WorldClockRow(
|
||||
text = time,
|
||||
// Tabular, because M7 made the headline role tabular
|
||||
// app-wide: a column of times must not jitter as it ticks.
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = timeStyle.style,
|
||||
// One line only when capped (seconds on); without seconds
|
||||
// the time wraps at a huge font scale, as it always did.
|
||||
maxLines = if (timeStyle.capped) 1 else Int.MAX_VALUE,
|
||||
softWrap = !timeStyle.capped,
|
||||
overflow = if (timeStyle.capped) TextOverflow.Ellipsis else TextOverflow.Clip,
|
||||
// The row's description already says the time, to the
|
||||
// minute; the ticking text itself stays out of the tree.
|
||||
modifier = Modifier
|
||||
.widthIn(max = timeStyle.maxWidth)
|
||||
.clearAndSetSemantics {},
|
||||
color = if (isDragging) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
@@ -147,6 +177,63 @@ internal fun WorldClockRow(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
val capped: Boolean get() = maxWidth != Dp.Infinity
|
||||
}
|
||||
|
||||
private const val FitMargin = 0.95f
|
||||
|
||||
/**
|
||||
* The least the time may take: wide enough for "12:59:59 PM" at the default
|
||||
* scale. On a wider window the cap grows with it, to [SecondsMaxShare] of the
|
||||
* window, so a larger font scale is honoured wherever there is room for it.
|
||||
*/
|
||||
private val SecondsMinCap = 168.dp
|
||||
|
||||
private const val SecondsMaxShare = 0.45f
|
||||
|
||||
/**
|
||||
* 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 the cap, 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
|
||||
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
|
||||
val windowWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxWidth = maxOf(SecondsMinCap, windowWidth * SecondsMaxShare)
|
||||
return remember(showSeconds, base, formatTime, density, measurer, maxWidth) {
|
||||
val style = base.copy(fontWeight = FontWeight.Bold)
|
||||
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) { maxWidth.toPx() }
|
||||
// Through px and back, not by scaling the sp value: from Android 14
|
||||
// large font scales are non-linear, so sp and px are not proportional.
|
||||
val fitted = if (width <= cap) {
|
||||
style
|
||||
} else {
|
||||
// A few percent spare: hinted glyph advances do not scale exactly.
|
||||
style.copy(fontSize = with(density) { (style.fontSize.toPx() * FitMargin * cap / width).toSp() })
|
||||
}
|
||||
RowTimeStyle(fitted, maxWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The offset, and the day only when it differs: "Same time as home" stands
|
||||
* alone, and "Today" after it would be a sentence saying nothing twice.
|
||||
|
||||
@@ -11,7 +11,6 @@ import de.jeanlucmakiola.clockula.domain.worldclock.ZoneEntry
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.ZoneOffsetFormat
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
@@ -20,7 +19,7 @@ internal data class WorldClockRowState(
|
||||
val zoneId: String,
|
||||
/** The user's label when set, else the ICU city, else the id's last segment. Never blank. */
|
||||
val name: String,
|
||||
/** Truncated to the minute. Non-null **exactly** when [known]. */
|
||||
/** Truncated to the minute, or the second with seconds on. Non-null **exactly** when [known]. */
|
||||
val time: LocalTime?,
|
||||
/** Non-null exactly when [known]. */
|
||||
val offset: OffsetLabel?,
|
||||
@@ -36,7 +35,7 @@ internal data class HomeFaceState(
|
||||
val zoneId: String,
|
||||
val city: String,
|
||||
val zoneName: String?,
|
||||
/** Truncated to the second. */
|
||||
/** Truncated to the minute, or the second with seconds on. */
|
||||
val time: LocalTime,
|
||||
/** 0f night … 1f day — what the dial's shape morph carries. */
|
||||
val dayFraction: Float,
|
||||
@@ -51,6 +50,8 @@ internal sealed interface WorldClockUiState {
|
||||
/** In the repository's order — `sort_order`, then `id`. Never re-sorted here. */
|
||||
val rows: List<WorldClockRowState>,
|
||||
val canAdd: Boolean,
|
||||
/** The user's "show seconds": the readouts' format and the face's second hand. */
|
||||
val showSeconds: Boolean,
|
||||
) : WorldClockUiState
|
||||
}
|
||||
|
||||
@@ -62,8 +63,9 @@ internal object WorldClockRows {
|
||||
* knows keeps its row with all three readings blank rather than being
|
||||
* rewritten into something the app invented.
|
||||
*
|
||||
* The time is truncated to the **minute**, which is what makes the 200 ms
|
||||
* tick cheap: four ticks in five change nothing a row can show.
|
||||
* The time is truncated to the unit the tab ticks in, the **minute** unless
|
||||
* [showSeconds]: a state carrying more precision than is shown would
|
||||
* differ on every wake and change nothing anybody could see.
|
||||
*/
|
||||
fun from(
|
||||
clocks: List<WorldClock>,
|
||||
@@ -71,6 +73,7 @@ internal object WorldClockRows {
|
||||
now: Instant,
|
||||
entries: Map<String, ZoneEntry>,
|
||||
zoneNames: Map<String, String?>,
|
||||
showSeconds: Boolean,
|
||||
): List<WorldClockRowState> = clocks.map { clock ->
|
||||
val known = clock.isKnownZone
|
||||
val comparison = if (known) {
|
||||
@@ -82,7 +85,7 @@ internal object WorldClockRows {
|
||||
id = clock.id,
|
||||
zoneId = clock.zoneId,
|
||||
name = clock.label ?: cityOf(clock.zoneId, entries),
|
||||
time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(ChronoUnit.MINUTES),
|
||||
time = comparison?.localDateTime?.toLocalTime()?.truncatedTo(WorldClockDefaults.unitFor(showSeconds)),
|
||||
offset = comparison?.let { ZoneOffsetFormat.labelFor(it.offsetMinutes) },
|
||||
day = comparison?.let { ZoneOffsetFormat.dayLabelFor(it.dayDifference) },
|
||||
zoneName = zoneNames[clock.zoneId],
|
||||
@@ -96,11 +99,11 @@ internal object WorldClockRows {
|
||||
now: Instant,
|
||||
entries: Map<String, ZoneEntry>,
|
||||
zoneNames: Map<String, String?>,
|
||||
showSeconds: Boolean,
|
||||
): HomeFaceState {
|
||||
// To the second, not the millisecond: the second hand steps, so a state
|
||||
// that carried more precision would differ on every tick and change
|
||||
// nothing anybody could see.
|
||||
val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(ChronoUnit.SECONDS)
|
||||
// To the unit shown, as the rows are. Without seconds the face has no
|
||||
// second hand, so its hands and its readout move once a minute.
|
||||
val local = now.toJavaInstant().atZone(homeZone).toLocalTime().truncatedTo(WorldClockDefaults.unitFor(showSeconds))
|
||||
return HomeFaceState(
|
||||
zoneId = homeZone.id,
|
||||
city = cityOf(homeZone.id, entries),
|
||||
|
||||
@@ -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
|
||||
@@ -20,16 +21,21 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.text
|
||||
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
|
||||
@@ -65,10 +71,19 @@ 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 formatTime = rememberLocalTimeFormatter()
|
||||
val loaded = state as? WorldClockUiState.Loaded
|
||||
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).
|
||||
@@ -112,27 +127,43 @@ fun WorldClockScreen(
|
||||
},
|
||||
) {
|
||||
if (loaded != null) {
|
||||
HomeFace(home = loaded.home, formatTime = formatTime)
|
||||
HomeFace(
|
||||
home = loaded.home,
|
||||
showSeconds = loaded.showSeconds,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
)
|
||||
|
||||
if (loaded.rows.isEmpty()) {
|
||||
EmptyWorldClocks()
|
||||
} else {
|
||||
// The column is handed ids, not rows: it re-seeds its working
|
||||
// order whenever its items change, and rows that tick (every
|
||||
// second with seconds on) would snap a drag back mid-gesture.
|
||||
val rowsById = loaded.rows.associateBy { it.id }
|
||||
ReorderableColumn(
|
||||
items = loaded.rows,
|
||||
keyOf = { it.id },
|
||||
onReorder = { rows -> viewModel.onReorder(rows.map { it.id }) },
|
||||
items = loaded.rows.map { it.id },
|
||||
keyOf = { it },
|
||||
onReorder = viewModel::onReorder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
rowHeight = 80.dp,
|
||||
) { row, position, dragHandle, isDragging ->
|
||||
) { id, position, dragHandle, isDragging ->
|
||||
// Always present while the column re-seeds in composition;
|
||||
// drawn as nothing rather than crashing if that ever lags.
|
||||
val row = rowsById[id] ?: return@ReorderableColumn
|
||||
WorldClockRow(
|
||||
row = row,
|
||||
position = position,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
timeStyle = rowTimeStyle,
|
||||
dragHandle = dragHandle,
|
||||
isDragging = isDragging,
|
||||
onMoveUp = { viewModel.onMoveUp(row.id) },
|
||||
onMoveDown = { viewModel.onMoveDown(row.id) },
|
||||
onRemove = { pendingRemoval = row.id },
|
||||
// Remembered per id: a row that ticks every second must
|
||||
// not hand TalkBack new custom actions every second.
|
||||
onMoveUp = remember(id) { { viewModel.onMoveUp(id) } },
|
||||
onMoveDown = remember(id) { { viewModel.onMoveDown(id) } },
|
||||
onRemove = remember(id) { { pendingRemoval = id } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +215,12 @@ fun WorldClockScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
|
||||
private fun HomeFace(
|
||||
home: HomeFaceState,
|
||||
showSeconds: Boolean,
|
||||
formatTime: (LocalTime) -> String,
|
||||
formatSpokenTime: (LocalTime) -> String,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -195,6 +231,7 @@ private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
|
||||
WorldClockFace(
|
||||
time = home.time,
|
||||
dayFraction = home.dayFraction,
|
||||
showSecondHand = showSeconds,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
// One TalkBack stop for the time and whose time it is ("14:05, Home,
|
||||
@@ -206,12 +243,27 @@ private fun HomeFace(home: HomeFaceState, formatTime: (LocalTime) -> String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Spoken to the minute: with seconds on, a merged node whose text
|
||||
// changed every second would be re-announced every second. Set as
|
||||
// the text, not a description, so the merge still reads "Home",
|
||||
// the city and the zone name after it.
|
||||
val spokenTime = formatSpokenTime(home.time)
|
||||
Text(
|
||||
text = formatTime(home.time),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
// 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) },
|
||||
)
|
||||
// The face has no other label saying whose time it is, and a city
|
||||
// here is otherwise indistinguishable from a city in the list below.
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||
import de.jeanlucmakiola.clockula.data.zones.ZoneDirectory
|
||||
import de.jeanlucmakiola.clockula.domain.WorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
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
|
||||
import javax.inject.Singleton
|
||||
@@ -30,6 +33,7 @@ internal class WorldClockSource @Inject constructor(
|
||||
private val zones: ZoneProvider,
|
||||
private val wallClock: WallClock,
|
||||
private val ticker: Ticker,
|
||||
private val clockChanges: ClockChanges,
|
||||
) {
|
||||
/** What the picker needs before it can draw a row; null while it is still being built. */
|
||||
private data class Catalog(val entries: List<ZoneEntry>, val deviceCity: String)
|
||||
@@ -39,20 +43,36 @@ internal class WorldClockSource @Inject constructor(
|
||||
|
||||
/**
|
||||
* The tab's state. Both the instant and the device zone are re-read on
|
||||
* **every tick**, which is what makes `TIME_SET` and `TIMEZONE_CHANGED`
|
||||
* free: the tab self-heals within one tick and `SystemEventReceiver` needs
|
||||
* no new branch (D2).
|
||||
* **every tick**. [ClockChanges] restarts the tick, so `TIME_SET` and
|
||||
* `TIMEZONE_CHANGED` show at once rather than at the next minute, and the
|
||||
* restarted ticker measures its next wait 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.
|
||||
* `SystemEventReceiver` still needs no new branch (D2).
|
||||
*
|
||||
* The cost of a 200 ms cadence is paid back by the state being stable —
|
||||
* the face is truncated to the second and the rows to the minute — so
|
||||
* `distinctUntilChanged` collapses five emissions a second into about one
|
||||
* (D18).
|
||||
* [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) and a
|
||||
* `delay` stretched by a deep sleep (the main looper's clock stops while
|
||||
* the device sleeps) without waiting for the next minute.
|
||||
*
|
||||
* The tick is aligned to the wall clock and only as fine as the readouts:
|
||||
* once a minute, or once a second when the user turned seconds on (#23).
|
||||
* Everything shown is truncated to that same unit, so each tick is one new
|
||||
* state and `distinctUntilChanged` only drops the odd early wake (D18).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun state(): Flow<WorldClockUiState.Loaded> =
|
||||
combine(worldClocks.worldClocks(), storedHomeZoneId, ::Pair)
|
||||
.flatMapLatest { (clocks, storedHome) ->
|
||||
ticker.ticks(WorldClockDefaults.Tick).map { build(clocks, storedHome) }
|
||||
fun state(refreshes: Flow<Unit>): Flow<WorldClockUiState.Loaded> =
|
||||
combine(
|
||||
worldClocks.worldClocks(),
|
||||
storedHomeZoneId,
|
||||
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))
|
||||
.map { build(clocks, storedHome, seconds) }
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
@@ -101,6 +121,7 @@ internal class WorldClockSource @Inject constructor(
|
||||
private suspend fun build(
|
||||
clocks: List<WorldClock>,
|
||||
storedHome: String?,
|
||||
showSeconds: Boolean,
|
||||
): WorldClockUiState.Loaded {
|
||||
val now = wallClock.now()
|
||||
val homeZone = HomeZone.resolve(storedHome, zones.current())
|
||||
@@ -112,9 +133,10 @@ internal class WorldClockSource @Inject constructor(
|
||||
val entries = directory.entriesFor(zoneIds)
|
||||
val zoneNames = directory.displayNamesOf(zoneIds, now)
|
||||
return WorldClockUiState.Loaded(
|
||||
home = WorldClockRows.home(homeZone, now, entries, zoneNames),
|
||||
rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames),
|
||||
home = WorldClockRows.home(homeZone, now, entries, zoneNames, showSeconds),
|
||||
rows = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds),
|
||||
canAdd = clocks.size < WorldClocks.MAX,
|
||||
showSeconds = showSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +38,12 @@ 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()
|
||||
// `WhileSubscribed`, not `Eagerly`: the upstream is a 200 ms ticker and
|
||||
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.
|
||||
.stateIn(
|
||||
@@ -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
|
||||
|
||||
@@ -350,9 +350,11 @@
|
||||
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
|
||||
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</string>
|
||||
<string name="settings_section_clock">Clock</string>
|
||||
<string name="settings_clock_subtitle">Home time zone</string>
|
||||
<string name="settings_clock_subtitle">Home time zone and seconds</string>
|
||||
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
|
||||
<string name="settings_home_zone">Home time zone</string>
|
||||
<string name="settings_world_clock_seconds">Display time with seconds</string>
|
||||
<string name="settings_world_clock_seconds_hint">The world clock shows seconds and a second hand. Uses a little more battery.</string>
|
||||
<string name="settings_self_check">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_title">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
|
||||
|
||||
@@ -43,6 +43,7 @@ class BackupEngineTest {
|
||||
source.settings.setThemeMode(ThemeMode.DARK)
|
||||
source.settings.setSnoozeMinutes(7)
|
||||
source.settings.setHomeZoneId("America/New_York")
|
||||
source.settings.setWorldClockSeconds(true)
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
|
||||
@@ -68,6 +69,7 @@ class BackupEngineTest {
|
||||
assertThat(target.settings.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||
assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7)
|
||||
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
|
||||
assertThat(target.settings.worldClockSeconds.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -74,6 +74,7 @@ class BackupCodecTest {
|
||||
volumeButtons = VolumeButtonAction.SNOOZE,
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
worldClockSeconds = true,
|
||||
bedtime = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 15),
|
||||
@@ -109,6 +110,7 @@ class BackupCodecTest {
|
||||
dynamicColor = true,
|
||||
defaults = ClockDefaults(),
|
||||
timerPresets = emptyList(),
|
||||
worldClockSeconds = false,
|
||||
),
|
||||
)
|
||||
val decoded = BackupCodec.decode(BackupCodec.encode(original))
|
||||
@@ -463,6 +465,17 @@ class BackupCodecTest {
|
||||
assertThat(json).contains("\"default_timer_silence_after_minutes\": 0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before show-seconds existed keeps it off`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.worldClockSeconds).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show-seconds is written under its preference key`() {
|
||||
assertThat(BackupCodec.encode(sampleContent())).contains("\"world_clock_show_seconds\": true")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -94,6 +94,25 @@ class ClockPrefsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show-seconds round-trips without touching the clock defaults`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.defaults.test {
|
||||
assertThat(awaitItem()).isEqualTo(ClockDefaults())
|
||||
|
||||
prefs.setWorldClockSeconds(true)
|
||||
// A barrier on this same flow: the next item must be this write,
|
||||
// so a re-emission caused by the seconds write would come first.
|
||||
prefs.setSnoozeMinutes(7)
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(ClockDefaults(snoozeMinutes = 7))
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
assertThat(prefs.worldClockSeconds.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
@@ -93,6 +93,7 @@ class SettingsPrefsTest {
|
||||
timerVolumeRampSeconds = 99,
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
worldClockSeconds = false,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.testing.FakeWallClock
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The ticker is nothing but `delay`, which is why it is injected: under
|
||||
@@ -18,7 +22,19 @@ import kotlin.time.Duration.Companion.seconds
|
||||
*/
|
||||
class RealTickerTest {
|
||||
|
||||
private val ticker = RealTicker()
|
||||
private val wallClock = FakeWallClock()
|
||||
|
||||
private val ticker = RealTicker(wallClock)
|
||||
|
||||
/** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */
|
||||
private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L)
|
||||
|
||||
/** Lets virtual time pass with the wall clock moving in step, as a real one would. */
|
||||
private fun TestScope.advanceBoth(by: kotlin.time.Duration) {
|
||||
advanceTimeBy(by)
|
||||
wallClock.advance(by)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first tick arrives immediately`() = runTest {
|
||||
@@ -74,4 +90,58 @@ class RealTickerTest {
|
||||
|
||||
assertThat(ticks).hasSize(3)
|
||||
}
|
||||
|
||||
// --- aligned ---
|
||||
|
||||
@Test
|
||||
fun `an aligned tick arrives immediately, then on the next whole second`() = runTest {
|
||||
wallClock.instant = onASecond + 300.milliseconds
|
||||
var ticks = 0
|
||||
backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } }
|
||||
runCurrent()
|
||||
assertThat(ticks).isEqualTo(1)
|
||||
|
||||
advanceBoth(699.milliseconds)
|
||||
assertThat(ticks).isEqualTo(1)
|
||||
|
||||
advanceBoth(1.milliseconds)
|
||||
assertThat(ticks).isEqualTo(2)
|
||||
|
||||
advanceBoth(1.seconds)
|
||||
assertThat(ticks).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a minute cadence wakes on the minute and not in between`() = runTest {
|
||||
wallClock.instant = onASecond
|
||||
var ticks = 0
|
||||
backgroundScope.launch { ticker.alignedTicks(1.minutes).collect { ticks++ } }
|
||||
runCurrent()
|
||||
|
||||
advanceBoth(39.seconds)
|
||||
assertThat(ticks).isEqualTo(1)
|
||||
|
||||
advanceBoth(1.seconds)
|
||||
assertThat(ticks).isEqualTo(2)
|
||||
|
||||
advanceBoth(59.seconds)
|
||||
assertThat(ticks).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wall clock set mid-wait is caught up on the next boundary`() = runTest {
|
||||
wallClock.instant = onASecond
|
||||
var ticks = 0
|
||||
backgroundScope.launch { ticker.alignedTicks(1.seconds).collect { ticks++ } }
|
||||
runCurrent()
|
||||
|
||||
// The user sets the clock a quarter-second on while the ticker waits.
|
||||
wallClock.advance(250.milliseconds)
|
||||
advanceBoth(1.seconds)
|
||||
assertThat(ticks).isEqualTo(2)
|
||||
|
||||
// Re-measured from the new wall clock: three quarters to the boundary.
|
||||
advanceBoth(750.milliseconds)
|
||||
assertThat(ticks).isEqualTo(3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The aligned cadence's arithmetic: always in `(0, unit]`, so never a spin. */
|
||||
class WallBoundariesTest {
|
||||
|
||||
/** 2023-11-14T22:13:20Z: on a whole second, twenty seconds into its minute. */
|
||||
private val onASecond: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000L)
|
||||
|
||||
@Test
|
||||
fun `mid-second waits for the rest of that second`() {
|
||||
val now = onASecond + 250.milliseconds
|
||||
|
||||
assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(750.milliseconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly on a boundary waits a whole unit rather than none`() {
|
||||
assertThat(WallBoundaries.untilNext(onASecond, 1.seconds)).isEqualTo(1.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a minute boundary is the next whole minute, not sixty seconds on`() {
|
||||
assertThat(WallBoundaries.untilNext(onASecond, 1.minutes)).isEqualTo(40.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one millisecond short of a boundary waits that millisecond`() {
|
||||
val now = onASecond - 1.milliseconds
|
||||
|
||||
assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(1.milliseconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `before the epoch still lands on a boundary`() {
|
||||
val now = Instant.fromEpochMilliseconds(-250L)
|
||||
|
||||
assertThat(WallBoundaries.untilNext(now, 1.seconds)).isEqualTo(250.milliseconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waking where it says lands on a boundary`() {
|
||||
val now = onASecond + 12_345.milliseconds
|
||||
val woken = now + WallBoundaries.untilNext(now, 1.minutes)
|
||||
|
||||
assertThat(woken.toEpochMilliseconds() % 60_000L).isEqualTo(0L)
|
||||
assertThat(woken > now).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a unit under a millisecond is refused`() {
|
||||
assertThrows<IllegalArgumentException> { WallBoundaries.untilNext(onASecond, Duration.ZERO) }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -13,12 +14,26 @@ class FakeTicker : Ticker {
|
||||
|
||||
private val streams = mutableListOf<MutableSharedFlow<Unit>>()
|
||||
private val periods = mutableListOf<Duration>()
|
||||
private val units = mutableListOf<Duration>()
|
||||
|
||||
/** Every period asked for, in order, so a test can pin the cadence. */
|
||||
val requestedPeriods: List<Duration> get() = periods.toList()
|
||||
|
||||
/** Every aligned unit asked for, in order, so a test can pin that cadence too. */
|
||||
val requestedUnits: List<Duration> get() = units.toList()
|
||||
|
||||
override fun ticks(period: Duration): Flow<Unit> {
|
||||
periods += period
|
||||
return stream()
|
||||
}
|
||||
|
||||
/** Driven by [tick] like [ticks]: the alignment is `RealTicker`'s, tested there. */
|
||||
override fun alignedTicks(unit: Duration): Flow<Unit> {
|
||||
units += unit
|
||||
return stream()
|
||||
}
|
||||
|
||||
private fun stream(): Flow<Unit> {
|
||||
val stream = MutableSharedFlow<Unit>(replay = 1, extraBufferCapacity = 64)
|
||||
check(stream.tryEmit(Unit))
|
||||
streams += stream
|
||||
@@ -30,3 +45,16 @@ class FakeTicker : Ticker {
|
||||
streams.toList().forEach { it.emit(Unit) }
|
||||
}
|
||||
}
|
||||
|
||||
/** [ClockChanges] a test fires by hand, standing in for the system's broadcasts. */
|
||||
class FakeClockChanges : ClockChanges {
|
||||
|
||||
private val stream = MutableSharedFlow<Unit>(extraBufferCapacity = 64)
|
||||
|
||||
override fun changes(): Flow<Unit> = stream
|
||||
|
||||
/** One `TIME_SET`/`TIMEZONE_CHANGED` to every collector. */
|
||||
suspend fun fire() {
|
||||
stream.emit(Unit)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class WorldClockHarness(
|
||||
val wallClock: FakeWallClock = FakeWallClock(now)
|
||||
val zones: FakeZoneProvider = FakeZoneProvider()
|
||||
val ticker: FakeTicker = FakeTicker()
|
||||
val clockChanges: FakeClockChanges = FakeClockChanges()
|
||||
val names: FakeZoneNames = FakeZoneNames()
|
||||
|
||||
val store: PrefStore = PrefStore(
|
||||
@@ -58,6 +59,7 @@ class WorldClockHarness(
|
||||
zones = zones,
|
||||
wallClock = wallClock,
|
||||
ticker = ticker,
|
||||
clockChanges = clockChanges,
|
||||
)
|
||||
|
||||
val viewModel: WorldClockViewModel = WorldClockViewModel(
|
||||
|
||||
@@ -221,6 +221,18 @@ class SettingsViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(alarmScheduler.autoSilence?.fireAt).isEqualTo(wallClock.instant + 25.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the world clock's seconds switch writes through`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val viewModel = collecting(newViewModel(tempDir))
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onWorldClockSecondsChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((viewModel.state.value as SettingsUiState.Loaded).worldClockSeconds).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -11,14 +11,15 @@ import de.jeanlucmakiola.clockula.testing.UTC_ZONE
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* §5.7 — 18 cases. The tab as pure data: the row's name, its offset and day
|
||||
* relative to **home**, the truncation that makes the 200 ms tick cheap (D18),
|
||||
* and the row whose zone the device no longer knows (D14).
|
||||
* §5.7 — 18 cases, and #23's seconds. The tab as pure data: the row's name,
|
||||
* its offset and day relative to **home**, the truncation to the unit the tab
|
||||
* ticks in (D18), and the row whose zone the device no longer knows (D14).
|
||||
*/
|
||||
class WorldClockRowsTest {
|
||||
|
||||
@@ -59,7 +60,8 @@ class WorldClockRowsTest {
|
||||
homeZone: ZoneId = BERLIN,
|
||||
now: Instant = T0,
|
||||
zoneNames: Map<String, String?> = emptyMap(),
|
||||
) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames)
|
||||
showSeconds: Boolean = false,
|
||||
) = WorldClockRows.from(clocks, homeZone, now, entries, zoneNames, showSeconds)
|
||||
|
||||
// --- from ---
|
||||
|
||||
@@ -149,6 +151,29 @@ class WorldClockRowsTest {
|
||||
assertThat(after).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** #23 */
|
||||
@Test
|
||||
fun `with seconds on a row's time carries the second`() {
|
||||
val clocks = listOf(clock(zoneId = newYork))
|
||||
|
||||
val before = rows(clocks, now = T0, showSeconds = true).single()
|
||||
val after = rows(clocks, now = T0 + 1.seconds, showSeconds = true).single()
|
||||
|
||||
assertThat(before.time).isEqualTo(LocalTime.of(17, 13, 20))
|
||||
assertThat(after.time).isEqualTo(LocalTime.of(17, 13, 21))
|
||||
}
|
||||
|
||||
/** #23 */
|
||||
@Test
|
||||
fun `with seconds on the millisecond is still dropped`() {
|
||||
val clocks = listOf(clock(zoneId = newYork))
|
||||
|
||||
val before = rows(clocks, now = T0, showSeconds = true)
|
||||
val after = rows(clocks, now = T0 + 999.milliseconds, showSeconds = true)
|
||||
|
||||
assertThat(after).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** §5.7 #10 */
|
||||
@Test
|
||||
fun `the truncation does not freeze the row`() {
|
||||
@@ -217,18 +242,26 @@ class WorldClockRowsTest {
|
||||
|
||||
/** §5.7 #16 */
|
||||
@Test
|
||||
fun `the hero face is home, to the second`() {
|
||||
val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap())
|
||||
fun `the hero face is home, to the minute`() {
|
||||
val home = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false)
|
||||
|
||||
assertThat(home.zoneId).isEqualTo(berlin)
|
||||
assertThat(home.city).isEqualTo("Berlin")
|
||||
assertThat(home.time).isEqualTo(LocalTime.of(23, 13))
|
||||
}
|
||||
|
||||
/** #23 */
|
||||
@Test
|
||||
fun `with seconds on the hero face is home to the second`() {
|
||||
val home = WorldClockRows.home(BERLIN, T0 + 400.milliseconds, entries, emptyMap(), showSeconds = true)
|
||||
|
||||
assertThat(home.time).isEqualTo(LocalTime.of(23, 13, 20))
|
||||
}
|
||||
|
||||
/** §5.7 #17 */
|
||||
@Test
|
||||
fun `a home zone with no entry falls back to its last segment`() {
|
||||
val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap())
|
||||
val home = WorldClockRows.home(ZoneId.of("Asia/Ho_Chi_Minh"), T0, entries, emptyMap(), showSeconds = false)
|
||||
|
||||
assertThat(home.city).isEqualTo("Ho Chi Minh")
|
||||
assertThat(home.zoneName).isNull()
|
||||
@@ -237,8 +270,8 @@ class WorldClockRowsTest {
|
||||
/** §5.7 #18 */
|
||||
@Test
|
||||
fun `the dial's morph carries whether it is the middle of the night there`() {
|
||||
val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap())
|
||||
val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap())
|
||||
val night = WorldClockRows.home(BERLIN, T0, entries, emptyMap(), showSeconds = false)
|
||||
val day = WorldClockRows.home(UTC_ZONE, midday, entries, emptyMap(), showSeconds = false)
|
||||
|
||||
assertThat(night.dayFraction).isWithin(0.001f).of(0f)
|
||||
assertThat(day.dayFraction).isWithin(0.001f).of(1f)
|
||||
|
||||
+100
-4
@@ -20,6 +20,7 @@ import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
@@ -355,12 +356,36 @@ class WorldClockViewModelTest {
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.ticker.requestedPeriods).contains(WorldClockDefaults.Tick)
|
||||
assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.tickFor(showSeconds = false))
|
||||
assertThat(harness.ticker.requestedPeriods).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.9 #20 */
|
||||
/** #23: the tab wakes every second only while the user asked for seconds. */
|
||||
@Test
|
||||
fun `a second of wall clock moves the face and leaves the rows alone`(@TempDir tempDir: Path) =
|
||||
fun `turning seconds on and off moves the cadence with it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(worldClockHarness(tempDir))
|
||||
advanceUntilIdle()
|
||||
assertThat(harness.loaded().showSeconds).isFalse()
|
||||
|
||||
harness.settings.setWorldClockSeconds(true)
|
||||
advanceUntilIdle()
|
||||
assertThat(harness.loaded().showSeconds).isTrue()
|
||||
|
||||
harness.settings.setWorldClockSeconds(false)
|
||||
advanceUntilIdle()
|
||||
assertThat(harness.loaded().showSeconds).isFalse()
|
||||
|
||||
assertThat(harness.ticker.requestedUnits).containsExactly(
|
||||
WorldClockDefaults.tickFor(showSeconds = false),
|
||||
WorldClockDefaults.tickFor(showSeconds = true),
|
||||
WorldClockDefaults.tickFor(showSeconds = false),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
/** #23 */
|
||||
@Test
|
||||
fun `with seconds off a second of wall clock changes nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(
|
||||
worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(1))),
|
||||
@@ -372,9 +397,61 @@ class WorldClockViewModelTest {
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.loaded()).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** #23: a minute-long cadence must not make a zone change wait a minute. */
|
||||
@Test
|
||||
fun `a zone change shows at once even between minute ticks`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(worldClockHarness(tempDir))
|
||||
advanceUntilIdle()
|
||||
assertThat(harness.loaded().home.zoneId).isEqualTo("Europe/Berlin")
|
||||
|
||||
harness.zones.zone = ZoneId.of("Asia/Tokyo")
|
||||
harness.clockChanges.fire()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.loaded().home.zoneId).isEqualTo("Asia/Tokyo")
|
||||
}
|
||||
|
||||
/** #23: a set clock shows at once, and the tick restarts against the new time. */
|
||||
@Test
|
||||
fun `a clock set shows at once and restarts the minute tick`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(worldClockHarness(tempDir))
|
||||
advanceUntilIdle()
|
||||
val before = harness.loaded().home.time
|
||||
|
||||
harness.wallClock.advance(5.minutes)
|
||||
harness.clockChanges.fire()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.loaded().home.time).isEqualTo(before.plusMinutes(5))
|
||||
assertThat(harness.ticker.requestedUnits).containsExactly(
|
||||
WorldClockDefaults.tickFor(showSeconds = false),
|
||||
WorldClockDefaults.tickFor(showSeconds = false),
|
||||
)
|
||||
}
|
||||
|
||||
/** §5.9 #20, with #23's seconds on. */
|
||||
@Test
|
||||
fun `a second of wall clock moves the face and the rows when seconds are on`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(
|
||||
worldClockHarness(tempDir, dao = FakeWorldClockDao(storedClocks(1))),
|
||||
)
|
||||
harness.settings.setWorldClockSeconds(true)
|
||||
advanceUntilIdle()
|
||||
val before = harness.loaded()
|
||||
|
||||
harness.wallClock.advance(1.seconds)
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
val after = harness.loaded()
|
||||
assertThat(after.home.time).isEqualTo(before.home.time.plusSeconds(1))
|
||||
assertThat(after.rows).isEqualTo(before.rows)
|
||||
assertThat(after.rows.single().time).isEqualTo(before.rows.single().time?.plusSeconds(1))
|
||||
}
|
||||
|
||||
/** §5.9 #21 */
|
||||
@@ -413,6 +490,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("Берлін")
|
||||
}
|
||||
}
|
||||
+63
-15
@@ -594,9 +594,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/`.
|
||||
|
||||
@@ -605,12 +607,13 @@ one anywhere under `domain/` or `ui/`.
|
||||
## 6. Preferences
|
||||
|
||||
One DataStore file, `clockula_prefs`, behind floret-kit's typed `PrefStore`.
|
||||
Four slices:
|
||||
Six slices:
|
||||
|
||||
| Slice | Keys | Owner |
|
||||
|---|---|---|
|
||||
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
|
||||
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
|
||||
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
@@ -1619,6 +1622,16 @@ immediately and then every period (one second for the pill, since the pill
|
||||
formats to whole seconds), so a fresh subscriber is never blank. `RealTicker` is
|
||||
nothing but `delay`, which makes it testable under `runTest`'s virtual clock.
|
||||
|
||||
`alignedTicks(unit)` is the same seam for a readout of whole wall-clock units:
|
||||
after the immediate emission it waits `WallBoundaries.untilNext(now, unit)`,
|
||||
re-measured against `WallClock` after every wake, so it fires on the second or
|
||||
minute boundary itself and a `delay` that ran long does not accumulate. A
|
||||
clock set mid-wait is only caught up once that wait ends, so a caller on a
|
||||
long cadence restarts the flow on `ClockChanges`, the system's own word that
|
||||
the clock or zone moved. The world clock is its one caller (§16, "The
|
||||
cadence"), and does exactly that, so a long
|
||||
cadence never means a long wait for those.
|
||||
|
||||
### The dismiss challenge, and why it cannot strand anyone
|
||||
|
||||
`ChallengeGate` is a pure state machine whose single boolean `open` means
|
||||
@@ -2646,8 +2659,8 @@ ramp to 1 at 07:00, 1 until 17:00, a linear ramp back to 0 at 19:00. It needs no
|
||||
latitude, it has obvious tests, and the app never claims it is sunrise.
|
||||
|
||||
The rest is ordinary `Canvas` work over `AnalogFace`'s pure angles: twelve hour
|
||||
marks with the four quarters longer, three hands, a centre cap, all in scheme
|
||||
tokens. The morph is built once and the path re-derived into a reused
|
||||
marks with the four quarters longer, the hour and minute hands (and a second
|
||||
hand when seconds are on, below), a centre cap, all in scheme tokens. The morph is built once and the path re-derived into a reused
|
||||
`Path` per frame through `toPath(Morph, Float, Path)`, which is not
|
||||
`@Composable` and is therefore callable from a `DrawScope`. M8 left the digital
|
||||
time out from under the face on the grounds that an analog face that also
|
||||
@@ -2663,15 +2676,50 @@ refused for the live pill (§12).
|
||||
|
||||
### The cadence
|
||||
|
||||
`WorldClockDefaults.Tick = 200 ms`. `RealTicker` is a plain `delay` loop with no
|
||||
alignment to the wall second, so a one-second cadence would leave the second
|
||||
hand up to a full second behind the device clock, which is visible and not
|
||||
something a clock app ships. The cost is bounded by making the state **stable**:
|
||||
`HomeFaceState.time` is truncated to the second, `WorldClockRowState.time` to
|
||||
the minute, and the flow is `distinctUntilChanged`. The upstream emits five
|
||||
times a second, a new state is produced about once a second, and the rows' own
|
||||
values change once a minute. The period is a `Ticker` parameter, never a `delay`
|
||||
at a call site (§12).
|
||||
**Display time with seconds** (Settings > Clock, `world_clock_show_seconds`,
|
||||
off by default, #23) decides how often the tab wakes. Off, the face has an hour
|
||||
and a minute hand, the readout under it and every row show hours and minutes,
|
||||
and the tab ticks **once a minute**. On, the readout and the rows add seconds
|
||||
in the same 12/24-hour and locale form (`HH:mm:ss`, or the locale's medium
|
||||
time), the face gains its second hand, and the tab ticks **once a second**.
|
||||
The second hand belongs to the switch rather than being always there: a hand
|
||||
that steps every second is exactly what makes the tab wake every second, and
|
||||
the point of the switch is that nobody pays for that without asking.
|
||||
|
||||
Both cadences are `Ticker.alignedTicks` (§12), at
|
||||
`WorldClockDefaults.tickFor(showSeconds)`, which is derived from the same
|
||||
`unitFor` the readouts are truncated to, so a tick lands on the wall-clock boundary itself rather than
|
||||
up to a period late; a free-running one-second `delay` could leave the second
|
||||
hand a full second behind the device clock. This replaces M8's 200 ms
|
||||
free-running tick, which got within a fifth of a second only by waking five
|
||||
times as often as anything changed. `HomeFaceState.time` and
|
||||
`WorldClockRowState.time` are truncated to the same unit the tab ticks in, and
|
||||
the flow stays `distinctUntilChanged`, which now only absorbs the odd early
|
||||
wake. The setting is read with the stored list and the home zone, so flipping
|
||||
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 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. 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
|
||||
change recreates the activity but not the ViewModel and is announced by no
|
||||
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
|
||||
the ticking text is kept out of the tree, because a focused node whose text
|
||||
changed every second would be re-announced every second.
|
||||
|
||||
### Twenty-four cities, and a reorder a screen reader can reach
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_alarm_silence_after_minutes": 10,
|
||||
"default_timer_silence_after_minutes": 10,
|
||||
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
||||
"world_clock_show_seconds": false,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
"enabled": false,
|
||||
@@ -199,6 +200,10 @@ hour). A file without them, from before the setting existed, imports as `10`,
|
||||
the value it was made with. A value of 60 or more reads as 0, "Never" (which
|
||||
rings for that long anyway); a negative one reads as the default, 10.
|
||||
|
||||
Files from before `world_clock_show_seconds` existed import with it off, its
|
||||
default. It travels with the settings but is not a clock default: nothing an
|
||||
alarm or timer inherits depends on it.
|
||||
|
||||
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
|
||||
as the existing preference setters, never on parse.
|
||||
|
||||
|
||||
+3
-1
@@ -453,7 +453,9 @@ deliberate showpiece.
|
||||
January and eight in July; labels are data, not strings, so Kathmandu is
|
||||
`Ahead(4, 45)` rather than a signed 285 for a translator to interpret; the
|
||||
cadence is 200 ms with the state truncated to the second (face) and the
|
||||
minute (rows) and `distinctUntilChanged` over it; the list is capped at
|
||||
minute (rows) and `distinctUntilChanged` over it (since #23, a tick aligned
|
||||
to the wall clock: once a minute, or once a second with "Display time with
|
||||
seconds" on); the list is capped at
|
||||
24, with the cap spelled out on the screen once it is reached, because
|
||||
M3's `ExtendedFloatingActionButton` has no `enabled` to dim; and
|
||||
reduce-motion snaps the morph but never stops the second hand,
|
||||
|
||||
Reference in new issue
Block a user