diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6499057..832aa80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -103,3 +103,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
analog and digital clocks that show your world clocks at larger sizes. Each
widget can follow the system theme or stay light or dark, and choose what a
tap opens.
+- **Screen saver.** A full-screen digital or analog clock with the date and
+ your next alarm, for a phone on a charger or a dock. Night mode turns it
+ very dim for a dark bedroom, and the clock moves a little every minute so
+ nothing burns into the screen. Pick it in Android's screen saver settings,
+ or open it any time from the menu on each tab. Its settings are in Settings
+ → Clock and are included in backups.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6c64279..15f4649 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -285,6 +285,51 @@
android:name=".bedtime.receiver.BedtimeReceiver"
android:exported="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
? = null,
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
val bedtime: BedtimeDto? = null,
+ /** Absent in files from before the screen saver; they restore its defaults. */
+ @SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
+ @SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
)
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt
index b7e5a05..970af0b 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt
@@ -6,6 +6,7 @@ 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.AndroidHourFormat
import de.jeanlucmakiola.clockula.data.time.RealTicker
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
@@ -13,6 +14,7 @@ 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.HourFormat
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -44,4 +46,7 @@ abstract class TimeModule {
@Binds
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
+
+ @Binds
+ abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt
new file mode 100644
index 0000000..0b9c32a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt
@@ -0,0 +1,14 @@
+package de.jeanlucmakiola.clockula.data.prefs
+
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
+import de.jeanlucmakiola.floret.prefs.Pref
+import de.jeanlucmakiola.floret.prefs.booleanPref
+import de.jeanlucmakiola.floret.prefs.enumPref
+
+/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */
+object ScreenSaverPrefs {
+
+ val style: Pref = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
+
+ val nightMode: Pref = booleanPref("screen_saver_night_mode", false)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
index 42d0208..1555002 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
@@ -129,6 +131,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
+ prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
+ prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
}
}
@@ -271,6 +275,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
}
+ // --- screen saver (issue #25) ---
+
+ /** Built once and composed from its two keys, for the same reasons [defaults] is. */
+ val screenSaver: Flow = combine(
+ store.flow(ScreenSaverPrefs.style),
+ store.flow(ScreenSaverPrefs.nightMode),
+ ::ScreenSaverSettings,
+ ).distinctUntilChanged()
+
+ suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style)
+
+ suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
+
// --- first-launch bookkeeping (M4) ---
/**
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
index 2397192..f5209ee 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
@@ -4,9 +4,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.text.format.DateFormat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
+import de.jeanlucmakiola.clockula.domain.time.HourFormat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -40,3 +42,10 @@ class AndroidClockChanges @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) }
}
}
+
+/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
+class AndroidHourFormat @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : HourFormat {
+ override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
index 55bb73d..0d48ba7 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
@@ -63,6 +64,7 @@ data class BackupSettings(
val timerPresets: List,
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
+ val screenSaver: ScreenSaverSettings,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt
new file mode 100644
index 0000000..36b6fd5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt
@@ -0,0 +1,17 @@
+package de.jeanlucmakiola.clockula.domain.screensaver
+
+import java.time.LocalDate
+
+/**
+ * How the screen saver names the day of the next alarm. A weekday alone
+ * ("Thu") is only unambiguous for the coming six days: a weekly alarm whose
+ * time has passed today next rings in seven, and a skipped one in fourteen,
+ * and either would read as today's. Those carry their date as well.
+ */
+object ScreenSaverAlarmDay {
+
+ private const val DAYS_A_WEEKDAY_NAMES = 7L
+
+ fun weekdayIsEnough(fireDate: LocalDate, today: LocalDate): Boolean =
+ fireDate.isBefore(today.plusDays(DAYS_A_WEEKDAY_NAMES))
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt
new file mode 100644
index 0000000..f92c1d7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt
@@ -0,0 +1,47 @@
+package de.jeanlucmakiola.clockula.domain.screensaver
+
+import kotlin.time.Instant
+
+/**
+ * Where the screen saver's clock sits, so a face left up all night never
+ * burns into an OLED panel: it moves to a new spot once a minute, as AOSP's
+ * DeskClock screen saver does.
+ *
+ * A position is a pair of fractions of the *free* space — the screen minus
+ * the clock itself — so `0f` is flush with the left/top edge and `1f` with
+ * the right/bottom one, at any screen and clock size. Derived from the minute
+ * alone rather than from a random source, so the clock lands on the same spot
+ * however often the minute is re-read (a recomposition, a time change) and
+ * moves only when the minute does.
+ */
+object ScreenSaverDrift {
+
+ data class Position(val x: Float, val y: Float)
+
+ /** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */
+ fun positionAt(now: Instant): Position {
+ val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE)
+ val bits = mix(minute)
+ return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16))
+ }
+
+ /** The low 24 bits of [bits] as a fraction in `[0, 1]`. */
+ private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24
+
+ /**
+ * SplitMix64's finaliser: consecutive minutes land on unrelated spots,
+ * where a plain modulo would walk the clock across the screen in a line.
+ */
+ private fun mix(seed: Long): Long {
+ var z = seed + GOLDEN_GAMMA
+ z = (z xor (z ushr 30)) * MIX_1
+ z = (z xor (z ushr 27)) * MIX_2
+ return z xor (z ushr 31)
+ }
+
+ private const val SECONDS_PER_MINUTE = 60L
+ private const val MASK_24 = 0xFFFFFFL
+ private const val GOLDEN_GAMMA = -0x61c8864680b583ebL
+ private const val MIX_1 = -0x40a7b892e31b1a47L
+ private const val MIX_2 = -0x6b2fb644ecceee15L
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt
new file mode 100644
index 0000000..2ff1cdb
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt
@@ -0,0 +1,14 @@
+package de.jeanlucmakiola.clockula.domain.screensaver
+
+/** The screen saver's two faces: the time as numerals, or the world clock's dial. */
+enum class ScreenSaverStyle { DIGITAL, ANALOG }
+
+/**
+ * What the screen saver shows. [nightMode] is Google Clock's "very dim
+ * display, for dark rooms": dim colours on black and the display itself
+ * turned down, for a phone on a bedside table.
+ */
+data class ScreenSaverSettings(
+ val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL,
+ val nightMode: Boolean = false,
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt
index 4bb48b0..984d94a 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt
@@ -51,11 +51,16 @@ interface Ticker {
}
/**
- * 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.
+ * The device's clock being changed under a running readout: the time set by
+ * hand or by the network, the zone, or the 12/24-hour choice, which the
+ * platform announces as a time change too. Emits once per change while
+ * collected. Injected, so a test hands the change over instead of making one.
*/
interface ClockChanges {
fun changes(): Flow
}
+
+/** The device's 12/24-hour choice, read afresh on every call. */
+interface HourFormat {
+ fun is24Hour(): Boolean
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt
new file mode 100644
index 0000000..e6bb542
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt
@@ -0,0 +1,25 @@
+package de.jeanlucmakiola.clockula.domain.time
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Instant
+
+/**
+ * How long a minute-resolution readout may sleep: until the next wall-clock
+ * minute begins, so it changes on the minute rather than up to a minute late,
+ * and wakes once a minute rather than on a fixed sub-second cadence.
+ */
+object MinuteBoundary {
+
+ private const val MILLIS_PER_MINUTE = 60_000L
+
+ /**
+ * From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a
+ * boundary is a whole minute away, never zero, so a caller looping on it
+ * cannot spin.
+ */
+ fun untilNext(now: Instant): Duration {
+ val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE)
+ return (MILLIS_PER_MINUTE - intoMinute).milliseconds
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
index cd5d757..ca2df9d 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
@@ -16,10 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
-import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,6 +33,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
+import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -78,12 +77,7 @@ fun AlarmsScreen(
title = stringResource(R.string.tab_alarms),
scrollable = false,
actions = {
- IconButton(onClick = onOpenSettings) {
- Icon(
- imageVector = Icons.Rounded.Settings,
- contentDescription = stringResource(R.string.settings_title),
- )
- }
+ ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
floatingActionButton = {
FloatingActionButton(
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 3abe881..4646e90 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
@@ -6,9 +6,13 @@ 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
+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
@@ -27,20 +31,29 @@ internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTim
// 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, 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(if (withSeconds) "HH:mm:ss" else "HH:mm")
- } else {
- DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT)
- }
+ val locale = currentLocale()
+ val formatter = remember(configuration, locale, withSeconds) {
+ DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale, withSeconds), 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, withSeconds: Boolean = false): String =
+ if (is24Hour) {
+ if (withSeconds) "HH:mm:ss" else "HH:mm"
+ } else {
+ val style = if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT
+ DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, style, 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
new file mode 100644
index 0000000..04b3d1f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt
@@ -0,0 +1,71 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.service.dreams.DreamService
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * Clockula's screen saver, as the system starts it — on a charger or a dock,
+ * from Settings → Display → Screen saver. Non-interactive: any touch wakes
+ * the device, as a screen saver should. Night mode turns the display down to
+ * the system's dim level ([setScreenBright]) and the window's own
+ * brightness to the same near-zero the in-app host uses, on top of the
+ * dimmed colours.
+ *
+ * Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its
+ * lifecycle, view-model store and saved-state owners on the window's decor
+ * view, where [DreamViewOwner] puts them before the view is attached.
+ *
+ * Its settings screen is [ScreenSaverSettingsActivity], named in
+ * `res/xml/screen_saver_info.xml`.
+ */
+@AndroidEntryPoint
+class ClockulaDreamService : DreamService() {
+
+ @Inject internal lateinit var source: ScreenSaverSource
+
+ private var owner: DreamViewOwner? = null
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ isInteractive = false
+ isFullscreen = true
+ // Dim until the settings say otherwise (ScreenSaverRoot).
+ isScreenBright = false
+ window.applyScreenSaverBrightness(nightMode = true)
+
+ val owner = DreamViewOwner().also { owner = it }
+ owner.create(window.decorView)
+ setContentView(
+ // The window's context, not the service's: a Service is not a UI
+ // context, and the window's knows its display's density and size.
+ ComposeView(window.context).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ setContent {
+ ScreenSaverRoot(source, onNightModeChanged = { night ->
+ isScreenBright = !night
+ window.applyScreenSaverBrightness(night)
+ })
+ }
+ },
+ )
+ }
+
+ override fun onDreamingStarted() {
+ super.onDreamingStarted()
+ owner?.start()
+ }
+
+ override fun onDreamingStopped() {
+ owner?.stop()
+ super.onDreamingStopped()
+ }
+
+ override fun onDetachedFromWindow() {
+ owner?.destroy()
+ owner = null
+ super.onDetachedFromWindow()
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt
new file mode 100644
index 0000000..0decb7e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt
@@ -0,0 +1,57 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.view.View
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+
+/**
+ * The lifecycle, view-model store and saved-state registry a `ComposeView`
+ * expects to find on its window, for a window no `ComponentActivity` owns —
+ * a `DreamService`'s. Without them the `ComposeView` throws on attach.
+ *
+ * The lifecycle follows the dream: created with its window, resumed while it
+ * is dreaming, stopped when it stops, destroyed when the window goes. A
+ * stopped lifecycle is what makes `collectAsStateWithLifecycle` let go of the
+ * minute ticks, so a dream that is no longer shown costs nothing.
+ */
+internal class DreamViewOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
+
+ private val registry = LifecycleRegistry(this)
+ private val savedState = SavedStateRegistryController.create(this)
+
+ override val lifecycle: Lifecycle get() = registry
+ override val viewModelStore: ViewModelStore = ViewModelStore()
+ override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
+
+ /** Restores (nothing — a dream keeps no state across windows) and moves to CREATED, then installs itself on [root]. */
+ fun create(root: View) {
+ savedState.performRestore(null)
+ registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ root.setViewTreeLifecycleOwner(this)
+ root.setViewTreeViewModelStoreOwner(this)
+ root.setViewTreeSavedStateRegistryOwner(this)
+ }
+
+ fun start() {
+ registry.currentState = Lifecycle.State.RESUMED
+ }
+
+ fun stop() {
+ if (registry.currentState.isAtLeast(Lifecycle.State.STARTED)) registry.currentState = Lifecycle.State.CREATED
+ }
+
+ fun destroy() {
+ if (registry.currentState == Lifecycle.State.INITIALIZED) return
+ registry.currentState = Lifecycle.State.DESTROYED
+ viewModelStore.clear()
+ }
+}
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
new file mode 100644
index 0000000..e00bca6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt
@@ -0,0 +1,98 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.BatteryManager
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+/**
+ * The screen saver on demand, from the tabs' overflow menu. An app cannot
+ * start the system's dream without a system permission, so this shows the
+ * same [ScreenSaverRoot] full-screen instead, the way Google Clock's own
+ * "Screen saver" item does.
+ *
+ * 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.
+ * 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() {
+
+ @Inject internal lateinit var source: ScreenSaverSource
+
+ private val powerReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent)
+ }
+
+ /** The flag last applied; null until the first battery state. Changing a window flag costs a relayout, and the battery reports far more often than the plug changes. */
+ private var keepingScreenOn: Boolean? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setShowWhenLocked(true)
+ window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
+ window.applyScreenSaverBrightness(nightMode = true)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowCompat.getInsetsController(window, window.decorView).apply {
+ hide(WindowInsetsCompat.Type.systemBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ setContent { ScreenSaverRoot(source, onNightModeChanged = window::applyScreenSaverBrightness) }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ // ACTION_BATTERY_CHANGED is sticky: registering returns the current
+ // state at once, then every change while the screen saver is shown.
+ val sticky = ContextCompat.registerReceiver(
+ this,
+ powerReceiver,
+ IntentFilter(Intent.ACTION_BATTERY_CHANGED),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ sticky?.let(::applyKeepScreenOn)
+ }
+
+ override fun onStop() {
+ unregisterReceiver(powerReceiver)
+ super.onStop()
+ // Gone once it is no longer shown — Home, Recents, or the screen
+ // timing out on battery — as a dream ends, so the next wake shows the
+ // lock screen and the next launch the app, never a screen saver left
+ // behind. A rotation recreates nothing (configChanges in the manifest).
+ if (!isChangingConfigurations) finish()
+ }
+
+ override fun onUserInteraction() {
+ super.onUserInteraction()
+ finish()
+ }
+
+ private fun applyKeepScreenOn(battery: Intent) {
+ val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
+ if (charging == keepingScreenOn) return
+ keepingScreenOn = charging
+ if (charging) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+}
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
new file mode 100644
index 0000000..7866389
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt
@@ -0,0 +1,305 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.displayCutout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Alarm
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.LocalDensity
+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.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverAlarmDay
+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.format.DateTimeFormatter
+import java.time.temporal.TemporalAdjusters
+import java.util.Locale
+import kotlin.math.roundToInt
+
+/**
+ * The screen saver itself, as both hosts draw it: the time — numerals or the
+ * world clock's dial — the date and the next alarm, on black, at a spot that
+ * 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. Only a move fades: a new next alarm or
+ * a settings change redraws in place.
+ */
+@Composable
+internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
+ val reduceMotion = rememberReduceMotion()
+ BoxWithConstraints(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .windowInsetsPadding(WindowInsets.displayCutout)
+ .padding(ScreenSaverDefaults.Margin),
+ ) {
+ // 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.record(state)
+ Crossfade(
+ targetState = state.position,
+ animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
+ label = "drift",
+ ) { 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 (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() {
+ /**
+ * Re-inserted, not just replaced, so a spot the clock returns to (the
+ * time set back a minute) counts as newest and the frame fading out is
+ * never the one evicted.
+ */
+ fun record(state: ScreenSaverUiState) {
+ remove(state.position)
+ put(state.position, state)
+ }
+
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): 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) {
+ Layout(content = content, modifier = modifier.fillMaxSize()) { measurables, constraints ->
+ val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0))
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ placeable.place(
+ x = ((constraints.maxWidth - placeable.width).coerceAtLeast(0) * position.x).roundToInt(),
+ y = ((constraints.maxHeight - placeable.height).coerceAtLeast(0) * position.y).roundToInt(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun Clock(state: ScreenSaverUiState, side: Dp) {
+ val locale = currentLocale()
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ when (state.settings.style) {
+ ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side)
+ ScreenSaverStyle.ANALOG -> AnalogTime(state.time.toLocalTime(), state.use24Hour, locale, side)
+ }
+ Spacer(Modifier.height(ScreenSaverDefaults.Gap))
+ Text(
+ text = remember(state.time.toLocalDate(), locale) { dateLabel(state.time, locale) },
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (state.nextAlarm.hasAlarm) {
+ Spacer(Modifier.height(ScreenSaverDefaults.Gap / 2))
+ NextAlarm(state.nextAlarm, state.time, state.use24Hour, locale)
+ }
+ }
+}
+
+/**
+ * The time in numerals, scaled to the screen. In 12-hour time the AM/PM
+ * marker is kept — a bedside clock that cannot tell 7 in the morning from 7
+ * at night is no use — but drawn small, where the locale's own pattern puts
+ * it.
+ */
+@Composable
+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 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 * DIGITS_FRACTION / ems, side * 0.32f)
+ val fontSize = with(LocalDensity.current) { size.toSp() }
+ val text = buildAnnotatedString {
+ append(parts.before)
+ if (parts.marker.isNotEmpty()) {
+ withStyle(SpanStyle(fontSize = fontSize * MARKER_SCALE)) { append(parts.marker) }
+ }
+ append(parts.after)
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.displayLarge.copy(
+ fontSize = fontSize,
+ lineHeight = fontSize,
+ // Thin strokes: fewer lit pixels all night, and the look of a
+ // bedside clock rather than a banner.
+ fontWeight = FontWeight.Light,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ softWrap = false,
+ )
+}
+
+/**
+ * The world clock's own face, without its second hand (the screen saver
+ * redraws once a minute). The face hides itself from screen readers because
+ * on the World clock tab a text readout sits under it; here nothing does, so
+ * the face is given the time as its description.
+ */
+@Composable
+private fun AnalogTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
+ val description = remember(time, use24Hour, locale) { timeFormatter(use24Hour, locale).format(time) }
+ Box(Modifier.semantics { contentDescription = description }) {
+ WorldClockFace(
+ time = time,
+ dayFraction = DayNight.fractionAt(time),
+ modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION),
+ showSecondHand = false,
+ )
+ }
+}
+
+@Composable
+private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
+ val (day, time) = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmParts(model, now, use24Hour, locale) }
+ val label = stringResource(R.string.screen_saver_next_alarm_when, day, time)
+ val description = stringResource(R.string.screen_saver_next_alarm, label)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Alarm,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+}
+
+/** The AM/PM marker's size against the digits'. */
+private const val MARKER_SCALE = 0.3f
+
+/** 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(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 = localTimePattern(use24Hour, locale)
+ val markerAt = markerIndex(pattern)
+ if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
+ return runCatching {
+ TimeParts(
+ before = format(time, pattern.substring(0, markerAt), locale),
+ marker = format(time, "a", locale),
+ after = format(time, pattern.substring(markerAt + 1), locale),
+ )
+ }.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)
+
+/** The locale's own full weekday-and-date form, "Thursday, October 22". */
+private fun dateLabel(now: LocalDateTime, locale: Locale): String =
+ 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". The
+ * time follows the app's one 12/24-hour rule, like the big readout above it;
+ * the two are joined by a translatable string, so a language can reorder them.
+ */
+private fun nextAlarmParts(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): Pair {
+ // No date means the fire is within the coming week, on its weekday.
+ val today = now.toLocalDate()
+ val date = model.date ?: model.dayOfWeek?.let { today.with(TemporalAdjusters.nextOrSame(it)) } ?: today
+ val skeleton = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
+ return localizedDateFormatter(locale, skeleton).format(date) to
+ timeFormatter(use24Hour, locale).format(LocalTime.of(model.hour, model.minute))
+}
+
+private object ScreenSaverDefaults {
+ /** Keeps the clock off the very edge, where a curved panel bends it away. */
+ val Margin: Dp = 24.dp
+ val Gap: Dp = 16.dp
+
+ /** The analog face's share of the screen's shorter side. */
+ const val FACE_FRACTION = 0.55f
+
+ /** Slow enough to read as a drift, not a jump. */
+ const val MOVE_MILLIS = 1_200
+}
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
new file mode 100644
index 0000000..f1063f4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt
@@ -0,0 +1,60 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.view.Window
+import android.view.WindowManager
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme
+import de.jeanlucmakiola.floret.prefs.Appearance
+
+/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
+private const val NIGHT_BRIGHTNESS = 0.01f
+
+/**
+ * Both hosts' display brightness for [nightMode]: almost nothing at night,
+ * the user's own otherwise. The same window setting for the dream and the
+ * activity, so the system's screen saver is no brighter at night than the
+ * one opened from the menu.
+ */
+internal fun Window.applyScreenSaverBrightness(nightMode: Boolean) {
+ attributes = attributes.apply {
+ screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+ }
+}
+
+/**
+ * What both hosts set as their content: [source]'s state in the screen
+ * saver's theme. Collected with the host's lifecycle, so a stopped dream or
+ * 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 — 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 = null)
+ val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
+ val nightMode = state?.settings?.nightMode
+ LaunchedEffect(nightMode) { nightMode?.let(onNightModeChanged) }
+
+ 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))
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt
new file mode 100644
index 0000000..6d68caf
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt
@@ -0,0 +1,57 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.graphics.Color
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
+import de.jeanlucmakiola.floret.components.CollapsingScaffold
+import de.jeanlucmakiola.floret.prefs.Appearance
+import de.jeanlucmakiola.floret.prefs.isDark
+import javax.inject.Inject
+
+/**
+ * The screen saver's settings as Android opens them: the gear next to
+ * Clockula in Settings → Display → Screen saver (`android:settingsActivity`
+ * in `res/xml/screen_saver_info.xml`). The same rows as Settings → Clock,
+ * in the app's own theme.
+ *
+ * Exported because only an exported activity can be started by the Settings
+ * app; it takes no extras and does nothing a user could not do in-app.
+ */
+@AndroidEntryPoint
+class ScreenSaverSettingsActivity : ComponentActivity() {
+
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
+ val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
+ // The bars follow the app's resolved theme, not the device's
+ // night flag, as MainActivity's do.
+ LaunchedEffect(darkTheme) {
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
+ navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
+ )
+ }
+ ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
+ CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) {
+ ScreenSaverSettingsSection(inApp = false)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt
new file mode 100644
index 0000000..b9c7632
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt
@@ -0,0 +1,105 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.content.Context
+import android.content.Intent
+import android.provider.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.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
+import de.jeanlucmakiola.floret.components.GroupedRow
+import de.jeanlucmakiola.floret.components.GroupedSectionHeader
+import de.jeanlucmakiola.floret.components.OptionPicker
+import de.jeanlucmakiola.floret.components.Position
+
+/**
+ * The screen saver's settings as a run of grouped rows: its style, night
+ * mode, and a way to see it now. [inApp] — Settings → Clock — puts them
+ * under their own header, and adds a last row into Android's own screen
+ * saver settings, where Clockula is chosen and its start condition set; the
+ * system's settings screen opens this one, so there both are left out.
+ * Nothing is drawn, header included, until the settings have been read.
+ */
+@Composable
+internal fun ScreenSaverSettingsSection(
+ inApp: Boolean,
+ viewModel: ScreenSaverSettingsViewModel = hiltViewModel(),
+) {
+ val settings by viewModel.state.collectAsStateWithLifecycle()
+ val current = settings ?: return
+ val context = LocalContext.current
+ var showStyle by rememberSaveable { mutableStateOf(false) }
+
+ if (inApp) {
+ Spacer(Modifier.height(16.dp))
+ GroupedSectionHeader(stringResource(R.string.screen_saver_title))
+ }
+
+ GroupedRow(
+ title = stringResource(R.string.screen_saver_style),
+ summary = styleLabel(current.style),
+ position = Position.Top,
+ onClick = { showStyle = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.screen_saver_night_mode),
+ summary = stringResource(R.string.screen_saver_night_mode_hint),
+ position = Position.Middle,
+ trailing = { Switch(checked = current.nightMode, onCheckedChange = viewModel::onNightModeChanged) },
+ onClick = { viewModel.onNightModeChanged(!current.nightMode) },
+ )
+ GroupedRow(
+ title = stringResource(R.string.screen_saver_preview),
+ position = if (inApp) Position.Middle else Position.Bottom,
+ onClick = { openScreenSaver(context) },
+ )
+ if (inApp) {
+ GroupedRow(
+ title = stringResource(R.string.screen_saver_system_settings),
+ summary = stringResource(R.string.screen_saver_system_settings_hint),
+ position = Position.Bottom,
+ onClick = { openSystemScreenSaverSettings(context) },
+ )
+ }
+
+ if (showStyle) {
+ OptionPicker(
+ title = stringResource(R.string.screen_saver_style),
+ options = ScreenSaverStyle.entries.toList(),
+ selected = current.style,
+ label = { styleLabel(it) },
+ onSelect = viewModel::onStyleSelected,
+ onDismiss = { showStyle = false },
+ )
+ }
+}
+
+@Composable
+private fun styleLabel(style: ScreenSaverStyle): String = stringResource(
+ when (style) {
+ ScreenSaverStyle.DIGITAL -> R.string.screen_saver_style_digital
+ ScreenSaverStyle.ANALOG -> R.string.screen_saver_style_analog
+ },
+)
+
+/** The app's own full-screen screen saver ([ScreenSaverActivity]). */
+internal fun openScreenSaver(context: Context) {
+ context.startActivity(Intent(context, ScreenSaverActivity::class.java))
+}
+
+/** Android's screen saver settings. Some builds leave the screen out; then the row does nothing rather than crash. */
+private fun openSystemScreenSaverSettings(context: Context) {
+ runCatching { context.startActivity(Intent(Settings.ACTION_DREAM_SETTINGS)) }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt
new file mode 100644
index 0000000..ff9ba23
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt
@@ -0,0 +1,36 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * The screen saver's two settings, for both places they are edited: Settings
+ * → Clock, and the system's own screen saver settings ([ScreenSaverSettingsActivity]).
+ * Every write lands at once, as everywhere else in Settings. Null until the
+ * first read, so a row never shows a default it is about to change.
+ */
+@HiltViewModel
+class ScreenSaverSettingsViewModel @Inject constructor(
+ private val settings: SettingsPrefs,
+) : ViewModel() {
+
+ val state: StateFlow = settings.screenSaver
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), null)
+
+ fun onStyleSelected(style: ScreenSaverStyle) = viewModelScope.launch { settings.setScreenSaverStyle(style) }
+
+ fun onNightModeChanged(enabled: Boolean) = viewModelScope.launch { settings.setScreenSaverNightMode(enabled) }
+
+ private companion object {
+ const val SUBSCRIPTION_GRACE_MS = 5_000L
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt
new file mode 100644
index 0000000..603be5a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt
@@ -0,0 +1,95 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import de.jeanlucmakiola.clockula.alarm.AlarmEngine
+import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
+import de.jeanlucmakiola.clockula.domain.time.ClockChanges
+import de.jeanlucmakiola.clockula.domain.time.HourFormat
+import de.jeanlucmakiola.clockula.domain.time.MinuteBoundary
+import de.jeanlucmakiola.clockula.domain.time.WallClock
+import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
+import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
+import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
+import de.jeanlucmakiola.floret.prefs.Appearance
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onStart
+import java.time.LocalDateTime
+import java.time.ZonedDateTime
+import java.time.temporal.ChronoUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.toJavaInstant
+
+/** One frame of the screen saver: everything it draws, resolved to the minute. */
+internal data class ScreenSaverUiState(
+ val settings: ScreenSaverSettings,
+ /** The device's local time, truncated to the minute. */
+ val time: LocalDateTime,
+ val use24Hour: Boolean,
+ val nextAlarm: NextAlarmWidgetModel,
+ val position: ScreenSaverDrift.Position,
+)
+
+/**
+ * Prefs + the alarm engine + the clocks → the screen saver's state, for both
+ * of its hosts: the system's `ClockulaDreamService` and the app's own
+ * `ScreenSaverActivity`.
+ *
+ * It wakes **once a minute**, on the minute ([MinuteBoundary]) — the face
+ * shows no seconds, so a sub-second ticker would only burn battery on a
+ * screen left up all night — and at once when the device's clock is changed
+ * ([ClockChanges]: the time, the zone, or 12/24-hour). The next alarm is the
+ * next-alarm widget's own model ([NextAlarmWidgetPresenter]), re-resolved on
+ * every wake so an alarm that just rang moves on to its next occurrence.
+ */
+@Singleton
+internal class ScreenSaverSource @Inject constructor(
+ private val settings: SettingsPrefs,
+ private val alarmEngine: AlarmEngine,
+ private val wallClock: WallClock,
+ private val zones: ZoneProvider,
+ private val clockChanges: ClockChanges,
+ private val hourFormat: HourFormat,
+) {
+
+ val appearance: Flow = settings.appearance
+
+ fun state(): Flow =
+ combine(settings.screenSaver, alarmEngine.upcoming(minuteTicks())) { screenSaver, upcoming ->
+ val now = wallClock.now()
+ val zone = zones.current()
+ ScreenSaverUiState(
+ settings = screenSaver,
+ time = ZonedDateTime.ofInstant(now.toJavaInstant(), zone)
+ .toLocalDateTime()
+ .truncatedTo(ChronoUnit.MINUTES),
+ use24Hour = hourFormat.is24Hour(),
+ nextAlarm = NextAlarmWidgetPresenter.present(upcoming, now, zone),
+ position = ScreenSaverDrift.positionAt(now),
+ )
+ }.distinctUntilChanged()
+
+ /**
+ * Emits at once, then at every minute boundary. A clock change restarts
+ * the wait, since a `delay` measured against the old time would land on
+ * the wrong boundary.
+ */
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun minuteTicks(): Flow = clockChanges.changes()
+ .onStart { emit(Unit) }
+ .flatMapLatest {
+ flow {
+ while (true) {
+ emit(Unit)
+ delay(MinuteBoundary.untilNext(wallClock.now()))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
index 4f6e3ca..d23bb78 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
@@ -8,12 +8,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsSection
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/**
- * The clock tab's settings: the home zone, and whether the tab shows seconds.
+ * The clock tab's settings: the home zone, whether the tab shows seconds and
+ * the automatic local clock. Then the screen saver's: it is a clock face, and
+ * the settings Android's own screen saver screen opens are the same rows.
* The zone picker itself is ViewModel state, drawn by the hub.
*/
@Composable
@@ -68,5 +71,7 @@ internal fun ClockSettingsScreen(
},
onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) },
)
+
+ ScreenSaverSettingsSection(inApp = true)
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt
new file mode 100644
index 0000000..09e1f00
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt
@@ -0,0 +1,58 @@
+package de.jeanlucmakiola.clockula.ui.shell
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.MoreVert
+import androidx.compose.material.icons.rounded.Nightlight
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.clockula.ui.screensaver.openScreenSaver
+
+/**
+ * Every tab's top-bar overflow, as Google Clock's: the screen saver, then
+ * Settings. The screen saver opens directly — it is an activity of its own,
+ * not a destination in the shell's graph.
+ */
+@Composable
+internal fun ShellOverflowMenu(onOpenSettings: () -> Unit) {
+ val context = LocalContext.current
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ IconButton(onClick = { expanded = true }) {
+ Icon(
+ imageVector = Icons.Rounded.MoreVert,
+ contentDescription = stringResource(R.string.menu_more_options),
+ )
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.screen_saver_title)) },
+ leadingIcon = { Icon(Icons.Rounded.Nightlight, contentDescription = null) },
+ onClick = {
+ expanded = false
+ openScreenSaver(context)
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.settings_title)) },
+ leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) },
+ onClick = {
+ expanded = false
+ onOpenSettings()
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt
index be83ee5..9538565 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt
@@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Settings
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -26,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
+import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.animateItemMotion
@@ -63,12 +60,7 @@ fun StopwatchScreen(
modifier = modifier,
scrollable = false,
actions = {
- IconButton(onClick = onOpenSettings) {
- Icon(
- imageVector = Icons.Rounded.Settings,
- contentDescription = stringResource(R.string.settings_title),
- )
- }
+ ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
) {
Column(
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt
new file mode 100644
index 0000000..579567e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt
@@ -0,0 +1,58 @@
+package de.jeanlucmakiola.clockula.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialExpressiveTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
+
+/**
+ * The screen saver's theme: always Clockula's dark scheme — wallpaper colours
+ * when [dynamicColor] — over true black, because the screen saver runs in a
+ * dark room more often than not and black pixels on an OLED panel are off.
+ *
+ * [nightMode] dims every role the face draws with towards black, so the
+ * faces keep their usual tokens (the analog face is the world clock's own
+ * `WorldClockFace`) and simply come out very dim. The window's brightness is
+ * turned down by the host as well; the two together are what makes the
+ * night mode readable without lighting the room.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ScreenSaverTheme(
+ nightMode: Boolean,
+ dynamicColor: Boolean,
+ content: @Composable () -> Unit,
+) {
+ ClockulaTheme(darkTheme = true, dynamicColor = dynamicColor) {
+ val base = MaterialTheme.colorScheme
+ val scheme = remember(base, nightMode) { base.forScreenSaver(nightMode) }
+ MaterialExpressiveTheme(
+ colorScheme = scheme,
+ typography = MaterialTheme.typography,
+ shapes = MaterialTheme.shapes,
+ motionScheme = MaterialTheme.motionScheme,
+ content = content,
+ )
+ }
+}
+
+/** How much of each role survives night mode: enough to read across a dark bedroom, little enough not to light it. */
+private const val NIGHT_LEVEL = 0.32f
+
+private fun ColorScheme.forScreenSaver(nightMode: Boolean): ColorScheme {
+ val black = copy(background = Color.Black, surface = Color.Black)
+ if (!nightMode) return black
+ fun Color.dim(): Color = lerp(Color.Black, this, NIGHT_LEVEL)
+ return black.copy(
+ primary = primary.dim(),
+ onSurface = onSurface.dim(),
+ onSurfaceVariant = onSurfaceVariant.dim(),
+ // The analog face's dial and marks.
+ secondaryContainer = secondaryContainer.dim(),
+ onSecondaryContainer = onSecondaryContainer.dim(),
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt
index 0741321..4681572 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt
@@ -8,10 +8,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
-import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -25,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
+import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.animateItemMotion
@@ -59,12 +58,7 @@ fun TimersScreen(
modifier = modifier,
scrollable = false,
actions = {
- IconButton(onClick = onOpenSettings) {
- Icon(
- imageVector = Icons.Rounded.Settings,
- contentDescription = stringResource(R.string.settings_title),
- )
- }
+ ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
floatingActionButton = {
// Only over a list: on an empty tab the keypad is already on
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt
index 204f998..92381d2 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt
@@ -44,6 +44,10 @@ import java.time.LocalTime
* Scheme tokens only, no literal colour. The face is hidden from screen
* readers: the time readout under it says the same time as text, so
* describing the face as well would read the time twice.
+ *
+ * The screen saver reuses it at its own size (a [modifier] size wins over
+ * the hero size) and without the second hand: it redraws once a minute, and
+ * a second hand that only moved once a minute would be a broken one.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt
index 9f5620b..77703f2 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt
@@ -10,7 +10,6 @@ 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
-import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -41,6 +40,7 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
+import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -102,12 +102,7 @@ fun WorldClockScreen(
contentDescription = stringResource(R.string.world_clock_home_action),
)
}
- IconButton(onClick = onOpenSettings) {
- Icon(
- imageVector = Icons.Rounded.Settings,
- contentDescription = stringResource(R.string.settings_title),
- )
- }
+ ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
floatingActionButton = {
val canAdd = loaded?.canAdd != false
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b718b48..0374d0f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -352,7 +352,7 @@
How timers ring. A timer can still pick its own sound in its editor.
Off. A timer set to Silent still vibrates
Clock
- Home time zone, seconds and local clock
+ Home time zone, seconds, local clock and screen saver
World clocks show how far each city is from your home time zone.
Home time zone
Display time with seconds
@@ -360,6 +360,20 @@
Automatic local clock
While you’re somewhere the time differs from home, show the local time under the home clock.
Needs a home time zone. Home follows the device now, so it’s always local time.
+ Screen saver
+ Clock style
+ Digital
+ Analog
+ Night mode
+ Very dim display, for dark rooms
+ Open screen saver
+ Choose when it starts
+ Pick Clockula as the screen saver in Android\'s settings
+
+ Next alarm: %1$s
+
+ %1$s %2$s
+ More options
Why might my alarm not ring?
Why might my alarm not ring?
Check exact-alarm, notification and battery settings
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 9230f70..686e75d 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -9,6 +9,14 @@
title-less so nothing flashes on the way through (M9 D3). -->
+
+
+