fix(screensaver): fade only on a move, survive dock and theme changes

The in-app screen saver also handles uiMode, density, font scale and
locale changes in place, so docking it or the dark-theme schedule no
longer recreates it at night brightness. Only a move crossfades; a new
next alarm or a settings change redraws in place, and the outgoing copy
keeps its own minute. The AM/PM split skips quoted pattern text, and
rememberLocalTimeFormatter reads the same currentLocale() as the screen
saver.
This commit is contained in:
2026-10-22 09:04:25 +02:00
parent be2c0ada04
commit cba16c940c
3 changed files with 39 additions and 12 deletions
+5 -4
View File
@@ -308,12 +308,13 @@
<!-- The same screen saver on demand, from the tabs' overflow menu: an
app cannot start the system's dream itself. Not exported; it
finishes itself once no longer shown. Rotating into a dock or a
stand re-lays it out rather than recreating it, which would
flash black and reset its brightness. -->
finishes itself once no longer shown. Rotating it into a stand,
docking it, or the dark-theme schedule firing re-lays it out
rather than recreating it, which would flash black and reset its
brightness. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|locale|layoutDirection"
android:exported="false"
android:theme="@style/Theme.Clockula.ScreenSaver" />
@@ -6,6 +6,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.locale.currentLocale
import java.time.LocalTime
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
@@ -26,8 +27,8 @@ internal fun rememberLocalTimeFormatter(): (LocalTime) -> String {
// 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 locale = Locale.getDefault(Locale.Category.FORMAT)
val locale = currentLocale()
val formatter = remember(configuration, locale) {
DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale), locale)
}
return remember(formatter) {
@@ -61,7 +61,8 @@ import kotlin.math.roundToInt
* moves once a minute ([ScreenSaverDrift]) so nothing stays lit in one place.
* The move is a slow crossfade rather than a slide, as DeskClock's is, and
* a cut under reduce motion: the old minute fades out where it was as the
* new one fades in at its new spot.
* new one fades in at its new spot. Only a move fades: a new next alarm or
* a settings change redraws in place.
*/
@Composable
internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
@@ -76,19 +77,31 @@ internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier =
// Sized from the screen's shorter side, so the clock is as large in
// landscape on a bedside dock as it is upright in the hand.
val side = minOf(maxWidth, maxHeight)
// The last frame drawn at each spot, so the outgoing copy keeps its
// own minute while it fades. Two are all a crossfade ever shows.
val frames = remember { RecentFrames() }
frames[state.position] = state
Crossfade(
targetState = state,
targetState = state.position,
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
label = "drift",
) { shown ->
) { position ->
val current = position == state.position
val shown = if (current) state else frames[position] ?: state
// The outgoing minute is only a picture while it fades: a screen
// reader finds one time on screen, never two.
val semantics = if (shown == state) Modifier else Modifier.clearAndSetSemantics {}
Drifting(shown.position, semantics) { Clock(shown, side) }
val semantics = if (current) Modifier else Modifier.clearAndSetSemantics {}
Drifting(position, semantics) { Clock(shown, side) }
}
}
}
/** The newest two frames by position; older ones have finished fading. */
private class RecentFrames : LinkedHashMap<ScreenSaverDrift.Position, ScreenSaverUiState>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ScreenSaverDrift.Position, ScreenSaverUiState>?): Boolean =
size > 2
}
/** Places its one child at [position]'s fractions of the space the child leaves free. */
@Composable
private fun Drifting(position: ScreenSaverDrift.Position, modifier: Modifier, content: @Composable () -> Unit) {
@@ -221,7 +234,7 @@ private data class TimeParts(val before: String, val marker: String, val after:
private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts {
val pattern = localTimePattern(use24Hour, locale)
val markerAt = pattern.indexOf('a')
val markerAt = markerIndex(pattern)
if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
return runCatching {
TimeParts(
@@ -232,6 +245,18 @@ private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): Time
}.getOrElse { TimeParts(format(time, pattern, locale), "", "") }
}
/** Where the pattern's AM/PM field is, skipping quoted literal text; -1 when it has none. */
private fun markerIndex(pattern: String): Int {
var quoted = false
pattern.forEachIndexed { index, char ->
when {
char == '\'' -> quoted = !quoted
!quoted && char == 'a' -> return index
}
}
return -1
}
private fun format(time: LocalTime, pattern: String, locale: Locale): String =
if (pattern.isBlank()) pattern else DateTimeFormatter.ofPattern(pattern, locale).format(time)