diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index bf77123..dab3129 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt index 25c09d3..d96bb79 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt index 7f04e18..763e5f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt @@ -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()) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt index 2dc90f3..58fa9d6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt index 3822dc8..dbfc96f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt @@ -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(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)) + } } }