diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76d4d0d..bc7a4a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,3 +62,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 cfe76b4..a3e159b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -273,6 +273,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
? = 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 afd6f72..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
@@ -5,12 +5,16 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
+import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
+import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat
import de.jeanlucmakiola.clockula.data.time.RealTicker
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
+import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
+import de.jeanlucmakiola.clockula.domain.time.HourFormat
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -39,4 +43,10 @@ abstract class TimeModule {
@Binds
abstract fun bindTicker(impl: RealTicker): Ticker
+
+ @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 b76785b..3da2fcb 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
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
+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
@@ -93,6 +95,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[TimerPrefs.presets] = backup.timerPresets
+ prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
+ prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
}
}
@@ -133,6 +137,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
}
+ // --- 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
new file mode 100644
index 0000000..4d47d47
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
@@ -0,0 +1,49 @@
+package de.jeanlucmakiola.clockula.data.time
+
+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
+import javax.inject.Inject
+
+/**
+ * `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver registered for as
+ * long as the flow is collected — a readout on screen, never the manifest's
+ * `SystemEventReceiver`, which repairs the alarm schedule and has no business
+ * waking a screen that is not there.
+ */
+class AndroidClockChanges @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : ClockChanges {
+
+ override fun changes(): Flow = callbackFlow {
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ trySend(Unit)
+ }
+ }
+ val filter = IntentFilter().apply {
+ addAction(Intent.ACTION_TIME_CHANGED)
+ addAction(Intent.ACTION_TIMEZONE_CHANGED)
+ }
+ // Not exported: both are protected system broadcasts, which reach an
+ // unexported receiver anyway, and nothing else should be able to.
+ ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ 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 eac440d..79ff6a8 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
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
+import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlin.time.Duration
import kotlin.time.Instant
@@ -56,4 +57,5 @@ data class BackupSettings(
val dynamicColor: Boolean,
val defaults: ClockDefaults,
val timerPresets: List,
+ val screenSaver: ScreenSaverSettings = ScreenSaverSettings(),
)
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 e943df4..9908927 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
@@ -41,3 +41,18 @@ interface ZoneProvider {
interface Ticker {
fun ticks(period: Duration): Flow
}
+
+/**
+ * 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 bdb6c1d..9622bac 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
@@ -10,10 +10,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
@@ -28,6 +26,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
@@ -71,12 +70,7 @@ fun AlarmsScreen(
modifier = modifier,
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/screensaver/ClockulaDreamService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt
new file mode 100644
index 0000000..25c09d3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt
@@ -0,0 +1,61 @@
+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]) 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
+
+ val owner = DreamViewOwner().also { owner = it }
+ owner.create(window.decorView)
+ setContentView(
+ ComposeView(this).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
+ setContent {
+ ScreenSaverRoot(source, onNightModeChanged = { night -> isScreenBright = !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..7f04e18
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt
@@ -0,0 +1,91 @@
+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.
+ * Night mode turns the window's own brightness down to almost nothing.
+ */
+@AndroidEntryPoint
+class ScreenSaverActivity : ComponentActivity() {
+
+ @Inject internal lateinit var source: ScreenSaverSource
+
+ private val powerReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ 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 = ::applyNightBrightness) }
+ }
+
+ 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()
+ }
+
+ override fun onUserInteraction() {
+ super.onUserInteraction()
+ finish()
+ }
+
+ private fun applyKeepScreenOn(battery: Intent) {
+ val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
+ if (charging) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+
+ private fun applyNightBrightness(nightMode: Boolean) {
+ window.attributes = window.attributes.apply {
+ screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
+ }
+ }
+
+ private companion object {
+ /** Near the bottom of the panel's range; not 0, which some panels take as "off". */
+ const val NIGHT_BRIGHTNESS = 0.01f
+ }
+}
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..f8d23cf
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt
@@ -0,0 +1,232 @@
+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
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+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 androidx.core.os.ConfigurationCompat
+import de.jeanlucmakiola.clockula.R
+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.worldclock.WorldClockFace
+import de.jeanlucmakiola.floret.identity.rememberReduceMotion
+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.
+ */
+@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)
+ Crossfade(
+ targetState = state.position,
+ animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
+ label = "drift",
+ ) { position ->
+ Drifting(position) { Clock(state, side) }
+ }
+ }
+}
+
+/** Places its one child at [position]'s fractions of the space the child leaves free. */
+@Composable
+private fun Drifting(position: ScreenSaverDrift.Position, 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 = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.ROOT
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ when (state.settings.style) {
+ ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side)
+ ScreenSaverStyle.ANALOG -> WorldClockFace(
+ time = state.time.toLocalTime(),
+ dayFraction = DayNight.fractionAt(state.time.toLocalTime()),
+ modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION),
+ showSecondHand = false,
+ )
+ }
+ 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) }
+ val digits = parts.before.length + parts.after.length
+ // About 0.6em per tabular digit or colon, so the readout fills ~90 % of
+ // the shorter side at any length.
+ val size = minOf(side * 0.9f / (digits.coerceAtLeast(4) * 0.6f), side * 0.4f)
+ val fontSize = with(LocalDensity.current) { size.toSp() }
+ val text = buildAnnotatedString {
+ append(parts.before)
+ if (parts.marker.isNotEmpty()) {
+ withStyle(SpanStyle(fontSize = fontSize * 0.3f)) { 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,
+ )
+}
+
+@Composable
+private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
+ val label = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmLabel(model, now, use24Hour, locale) }
+ 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,
+ )
+ }
+}
+
+/** 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 = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "Hmm" else "hmma")
+ val markerAt = pattern.indexOf('a')
+ 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), "", "") }
+}
+
+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 =
+ DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEEMMMMd"), locale).format(now)
+
+/**
+ * The next alarm as the platform's own screen saver writes it: the short
+ * weekday and the time, "Tue 7:00 AM", in the locale's order. The weekday
+ * is always there, so a time alone can never be read as today's.
+ */
+private fun nextAlarmLabel(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): String {
+ val day = model.dayOfWeek ?: now.dayOfWeek
+ val at = now.toLocalDate()
+ .with(TemporalAdjusters.nextOrSame(day))
+ .atTime(model.hour, model.minute)
+ val pattern = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "EEEHmm" else "EEEhmma")
+ return DateTimeFormatter.ofPattern(pattern, locale).format(at)
+}
+
+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..3822dc8
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt
@@ -0,0 +1,36 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+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
+
+/**
+ * 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.
+ */
+@Composable
+internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) {
+ val appearance by source.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
+ val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
+ val nightMode = state?.settings?.nightMode ?: false
+ LaunchedEffect(nightMode) { onNightModeChanged(nightMode) }
+
+ 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))
+ }
+}
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..72be824
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt
@@ -0,0 +1,49 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+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)
+ enableEdgeToEdge()
+ setContent {
+ val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
+ ClockulaTheme(
+ darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()),
+ 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..b86970b
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt
@@ -0,0 +1,93 @@
+package de.jeanlucmakiola.clockula.ui.screensaver
+
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+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.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] 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 it is left out.
+ */
+@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) }
+
+ 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 d669685..20241ff 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
@@ -7,11 +7,17 @@ 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.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
-/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */
+/**
+ * The clock tab's settings, and 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
internal fun ClockSettingsScreen(
state: SettingsUiState.Loaded,
@@ -28,5 +34,9 @@ internal fun ClockSettingsScreen(
position = Position.Alone,
onClick = viewModel::onOpenHomeZonePicker,
)
+
+ Spacer(Modifier.height(16.dp))
+ GroupedSectionHeader(stringResource(R.string.screen_saver_title))
+ 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 2c9ea05..36cf557 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
@@ -42,6 +42,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
@@ -49,6 +53,7 @@ internal fun WorldClockFace(
time: LocalTime,
dayFraction: Float,
modifier: Modifier = Modifier,
+ showSecondHand: Boolean = true,
) {
val dialColor = MaterialTheme.colorScheme.secondaryContainer
val markColor = MaterialTheme.colorScheme.onSecondaryContainer
@@ -82,7 +87,7 @@ internal fun WorldClockFace(
) {
drawDial(morph, progress, dialPath, dialMatrix, dialColor)
drawTicks(markColor)
- drawHands(time, markColor, accentColor)
+ drawHands(time, markColor, accentColor, showSecondHand)
}
}
@@ -127,11 +132,11 @@ private fun DrawScope.drawTicks(color: Color) {
}
}
-private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) {
+private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color, showSecondHand: Boolean) {
val radius = size.minDimension / 2f
hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor)
hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor)
- hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
+ if (showSecondHand) hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
drawCircle(accentColor, radius = 4.dp.toPx(), center = center)
drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx()))
}
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 abac9b9..2fdc54c 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
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
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
@@ -35,6 +34,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
@@ -87,12 +87,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 ad0a66c..ca234b8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -301,9 +301,21 @@
Sound for new timers
New timers ring with this sound. Each timer can still change it in its editor.
Clock
- Home time zone
+ Home time zone and screen saver
World clocks show how far each city is from your home time zone.
Home time zone
+ 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
+ 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). -->
+
+
+