fix(screensaver): start dim, lock on time and keep room to drift

Both hosts start at night-mode brightness and only brighten once the
settings say so, and the screen stays black until the settings and the
appearance are both read, so a dark room is never lit by a loading frame.
The on-demand screen saver lets the phone lock on its usual timeout and
stays up over the lock screen, so a phone left charging is not left
unlocked. The digital readout takes 70% of the shorter side, leaving
room to drift across it.

The next alarm is one localized date-time skeleton instead of a
hand-joined day and time, the date uses floret's localizedDateFormatter,
and the 12/24-hour pattern is shared with rememberLocalTimeFormatter.
This commit is contained in:
makiolaj committed 2026-10-22 08:29:02 +02:00
1 parent d744fa8d98
commit 23f2ee94ea
5 files changed
+64 -45

No files matched your search

@@ -7,8 +7,11 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import java.time.LocalTime
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
/**
* Honours the user's 12/24-hour setting. Deliberately not unit-tested: it is
@@ -24,19 +27,27 @@ internal fun rememberLocalTimeFormatter(): (LocalTime) -> String {
// choice rides the same configuration, so both are re-read with it.
val configuration = LocalConfiguration.current
val formatter = remember(configuration) {
// 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")
} else {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
}
val locale = Locale.getDefault(Locale.Category.FORMAT)
DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale), locale)
}
return remember(formatter) {
{ time -> formatter.format(time) }
}
}
/**
* The rule itself, as a pattern: the device's 12/24-hour choice, then the
* locale's own short form — the same two-step the ring notification already
* formats with. A pattern rather than a formatter for the screen saver, which
* splits it around the AM/PM marker to draw that smaller.
*/
internal fun localTimePattern(is24Hour: Boolean, locale: Locale): String =
if (is24Hour) {
"HH:mm"
} else {
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, FormatStyle.SHORT, IsoChronology.INSTANCE, locale)
}
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
@Composable
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
@@ -30,6 +30,8 @@ class ClockulaDreamService : DreamService() {
super.onAttachedToWindow()
isInteractive = false
isFullscreen = true
// Dim until the settings say otherwise (ScreenSaverRoot).
isScreenBright = false
val owner = DreamViewOwner().also { owner = it }
owner.create(window.decorView)
@@ -25,7 +25,12 @@ import javax.inject.Inject
* Any touch closes it, as it would end a dream. It keeps the screen on only
* while the phone is charging: on battery the display times out as it
* normally would, so a screen saver left open in a bag does not drain it.
* Night mode turns the window's own brightness down to almost nothing.
* Kept on or not, the phone still locks on its usual timeout
* (`FLAG_ALLOW_LOCK_WHILE_SCREEN_ON`, as DeskClock's screen saver does) and
* the screen saver carries on over the lock screen, so a phone left on the
* charger overnight is not left unlocked; a touch then lands on the keyguard.
* Night mode turns the window's own brightness down to almost nothing; the
* window starts that way, until the settings are known.
*/
@AndroidEntryPoint
class ScreenSaverActivity : ComponentActivity() {
@@ -38,6 +43,9 @@ class ScreenSaverActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
applyNightBrightness(true)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.text.format.DateFormat
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
@@ -44,15 +43,14 @@ import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import de.jeanlucmakiola.clockula.domain.worldclock.DayNight
import de.jeanlucmakiola.clockula.ui.common.localTimePattern
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockFace
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
import kotlin.math.roundToInt
@@ -131,10 +129,12 @@ private fun Clock(state: ScreenSaverUiState, side: Dp) {
private fun DigitalTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
val parts = remember(time, use24Hour, locale) { timeParts(time, use24Hour, locale) }
// About 0.6em per tabular digit or colon, the marker at its own smaller
// size, so the readout fills ~90 % of the shorter side at any length.
// size, so the readout spans DIGITS_FRACTION of the shorter side at any
// length — never all of it, or upright on a phone there would be no room
// left across the screen to drift into.
val ems = (parts.before.length + parts.after.length + parts.marker.length * MARKER_SCALE)
.coerceAtLeast(4f) * 0.6f
val size = minOf(side * 0.9f / ems, side * 0.4f)
val size = minOf(side * DIGITS_FRACTION / ems, side * 0.32f)
val fontSize = with(LocalDensity.current) { size.toSp() }
val text = buildAnnotatedString {
append(parts.before)
@@ -203,27 +203,18 @@ private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour
/** The AM/PM marker's size against the digits'. */
private const val MARKER_SCALE = 0.3f
/**
* The app's one 12/24-hour rule, as `rememberLocalTimeFormatter` applies it:
* `HH:mm`, or the locale's own short time. Spelled as a pattern here because
* the numerals need it split around the AM/PM marker; the same pattern, so the
* screen saver never writes a time the alarm list writes differently.
*/
private fun timePattern(use24Hour: Boolean, locale: Locale): String =
if (use24Hour) {
"HH:mm"
} else {
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, FormatStyle.SHORT, IsoChronology.INSTANCE, locale)
}
/** The digital readout's share of the screen's shorter side. */
private const val DIGITS_FRACTION = 0.7f
/** The app's one 12/24-hour rule ([localTimePattern]), so the screen saver never writes a time the alarm list writes differently. */
private fun timeFormatter(use24Hour: Boolean, locale: Locale): DateTimeFormatter =
DateTimeFormatter.ofPattern(timePattern(use24Hour, locale), locale)
DateTimeFormatter.ofPattern(localTimePattern(use24Hour, locale), locale)
/** A time split around its AM/PM marker; [marker] is empty in 24-hour time. */
private data class TimeParts(val before: String, val marker: String, val after: String)
private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts {
val pattern = timePattern(use24Hour, locale)
val pattern = localTimePattern(use24Hour, locale)
val markerAt = pattern.indexOf('a')
if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
return runCatching {
@@ -240,20 +231,20 @@ private fun format(time: LocalTime, pattern: String, locale: Locale): String =
/** The locale's own full weekday-and-date form, "Thursday, October 22". */
private fun dateLabel(now: LocalDateTime, locale: Locale): String =
DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEEMMMMd"), locale).format(now)
localizedDateFormatter(locale, "EEEEMMMMd").format(now)
/**
* The next alarm as the platform's own screen saver writes it: the short
* weekday and the time, "Tue 7:00 AM". The weekday is always there, so a
* time alone can never be read as today's, and an alarm a week or more away
* carries its date too ([ScreenSaverAlarmDay]), "Thu, Oct 29 7:00 AM".
* weekday and the time, "Tue 7:00 AM", laid out in the locale's own order.
* The weekday is always there, so a time alone can never be read as today's,
* and an alarm a week or more away carries its date too
* ([ScreenSaverAlarmDay]), "Thu, Oct 29, 7:00 AM".
*/
private fun nextAlarmLabel(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): String {
val date = model.date ?: now.toLocalDate()
val daySkeleton = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
val day = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, daySkeleton), locale).format(date)
val time = timeFormatter(use24Hour, locale).format(LocalTime.of(model.hour, model.minute))
return "$day $time"
val date = model.date ?: return ""
val day = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
val time = if (use24Hour) "HHmm" else "hmma"
return localizedDateFormatter(locale, day + time).format(date.atTime(model.hour, model.minute))
}
private object ScreenSaverDefaults {
@@ -19,18 +19,25 @@ import de.jeanlucmakiola.floret.prefs.Appearance
* a backgrounded activity stops waking for the minute as well.
*
* [onNightModeChanged] hands the night-mode choice back to the host, which
* owns the window and so the display's brightness.
* owns the window and so the display's brightness — only once it is known:
* both hosts start dim, so a night-mode screen saver never lights a dark room
* for the moment its settings take to load. Until the settings and the
* appearance have both arrived the screen stays black, rather than showing a
* frame in colours that are about to change.
*/
@Composable
internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) {
val appearance by source.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
val appearance by source.appearance.collectAsStateWithLifecycle<Appearance?>(initialValue = null)
val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
val nightMode = state?.settings?.nightMode ?: false
LaunchedEffect(nightMode) { onNightModeChanged(nightMode) }
val nightMode = state?.settings?.nightMode
LaunchedEffect(nightMode) { nightMode?.let(onNightModeChanged) }
ScreenSaverTheme(nightMode = nightMode, dynamicColor = appearance.dynamicColor) {
// Black until the first state lands, rather than a frame of anything else.
state?.let { ScreenSaverContent(it) }
?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
ScreenSaverTheme(nightMode = nightMode ?: true, dynamicColor = appearance?.dynamicColor ?: false) {
val ready = state
if (ready != null && appearance != null) {
ScreenSaverContent(ready)
} else {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
}
}
}