feat(screensaver): add a screen saver with a dim night mode

A DreamService shows a full-screen clock, the date and the next alarm,
digital or as the world clock's analog face, on black. Night mode dims
every colour towards black and turns the display down; the clock moves
to a new spot each minute so nothing burns in. It wakes only on the
minute and on a time, zone or 12/24-hour change.

The tabs' settings gear becomes an overflow menu with "Screen saver" and
"Settings". An app cannot start the system's dream without a system
permission, so that item opens an activity with the same content, which
keeps the screen on only while charging. Style and night mode live in
Settings > Clock and in the dream's own settings screen, and are carried
in backups; older backups restore the defaults.

Fixes #25
This commit is contained in:
makiolaj committed 2026-10-22 08:07:44 +02:00
1 parent 3f431a7f32
commit 1e29226364
43 files changed
+1506 -48

No files matched your search

+6
View File
@@ -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.
+36
View File
@@ -273,6 +273,42 @@
</intent-filter>
</receiver>
<!-- The screen saver (issue #25). The system binds it on a charger or a
dock once the user picks it in Settings → Display → Screen saver.
Exported, since the dream manager is another process, and guarded
by BIND_DREAM_SERVICE, a signature permission only the system
holds, so nothing else can bind it. -->
<service
android:name=".ui.screensaver.ClockulaDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/screen_saver_info" />
</service>
<!-- The screen saver's settings, opened by the gear beside it in the
system's screen saver list. Exported because only an exported
activity can be started by the Settings app; it reads no extras
and changes nothing a user could not change in-app. -->
<activity
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
android:exported="true"
android:label="@string/screen_saver_title"
android:theme="@style/Theme.Clockula" />
<!-- The same screen saver on demand, from the tabs' overflow menu: an
app cannot start the system's dream itself. Not exported. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:exported="false"
android:theme="@style/Theme.Clockula.ScreenSaver" />
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
@@ -103,5 +103,6 @@ class BackupEngine @Inject constructor(
dynamicColor = settings.dynamicColor.first(),
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
screenSaver = settings.screenSaver.first(),
)
}
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
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.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
@@ -128,6 +130,8 @@ object BackupCodec {
dismissChallenge = defaults.dismissChallenge.name,
homeZoneId = defaults.homeZoneId,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
)
// --- decode + validate ---
@@ -235,6 +239,10 @@ object BackupCodec {
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
screenSaver = ScreenSaverSettings(
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
nightMode = settingsDto.screenSaverNightMode,
),
)
return BackupResult.Success(
@@ -77,4 +77,7 @@ data class SettingsDto(
* built-in ones.
*/
@SerialName("timer_presets") val timerPresets: List<Long>? = 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,
)
@@ -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
}
@@ -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<ScreenSaverStyle> = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
val nightMode: Pref<Boolean> = booleanPref("screen_saver_night_mode", false)
}
@@ -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<ScreenSaverSettings> = 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) ---
/**
@@ -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<Unit> = 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)
}
@@ -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<Duration>,
val screenSaver: ScreenSaverSettings = ScreenSaverSettings(),
)
@@ -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
}
@@ -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,
)
@@ -41,3 +41,18 @@ interface ZoneProvider {
interface Ticker {
fun ticks(period: Duration): Flow<Unit>
}
/**
* 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<Unit>
}
/** The device's 12/24-hour choice, read afresh on every call. */
interface HourFormat {
fun is24Hour(): Boolean
}
@@ -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
}
}
@@ -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(
@@ -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()
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
@@ -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))
}
}
@@ -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)
}
}
}
}
}
@@ -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)) }
}
@@ -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<ScreenSaverSettings?> = 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
}
}
@@ -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<Appearance> = settings.appearance
fun state(): Flow<ScreenSaverUiState> =
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<Unit> = clockChanges.changes()
.onStart { emit(Unit) }
.flatMapLatest {
flow {
while (true) {
emit(Unit)
delay(MinuteBoundary.untilNext(wallClock.now()))
}
}
}
}
@@ -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)
}
}
@@ -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()
},
)
}
}
}
@@ -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(
@@ -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(),
)
}
@@ -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
@@ -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()))
}
@@ -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
+13 -1
View File
@@ -301,9 +301,21 @@
<string name="settings_timers_sound_subtitle">Sound for new timers</string>
<string name="settings_timer_sound_hint">New timers ring with this sound. Each timer can still change it in its editor.</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone</string>
<string name="settings_clock_subtitle">Home time zone and screen saver</string>
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
<string name="settings_home_zone">Home time zone</string>
<string name="screen_saver_title">Screen saver</string>
<string name="screen_saver_style">Clock style</string>
<string name="screen_saver_style_digital">Digital</string>
<string name="screen_saver_style_analog">Analog</string>
<string name="screen_saver_night_mode">Night mode</string>
<string name="screen_saver_night_mode_hint">Very dim display, for dark rooms</string>
<string name="screen_saver_preview">Open screen saver</string>
<string name="screen_saver_system_settings">Choose when it starts</string>
<string name="screen_saver_system_settings_hint">Pick Clockula as the screen saver in Android\'s settings</string>
<!-- Read by a screen reader for the alarm line on the screen saver; %1$s is the day and time, "Tue 7:00 AM". -->
<string name="screen_saver_next_alarm">Next alarm: %1$s</string>
<string name="menu_more_options">More options</string>
<string name="settings_self_check">Why might my alarm not ring?</string>
<string name="settings_self_check_title">Why might my alarm not ring?</string>
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
+8
View File
@@ -9,6 +9,14 @@
title-less so nothing flashes on the way through (M9 D3). -->
<style name="Theme.Clockula.Invisible" parent="android:Theme.Translucent.NoTitleBar" />
<!-- The screen saver activity: black from the first frame, so opening it
in a dark room never flashes the light window background. -->
<style name="Theme.Clockula.ScreenSaver" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<!-- An hour numeral on the analog widget's dial (`layout/widget_analog_*.xml`).
Its text size and inset are overridden per widget size at runtime. -->
<style name="WidgetAnalogNumeral">
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The screen saver's entry in Settings → Display → Screen saver: the gear
beside it opens ScreenSaverSettingsActivity. -->
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="de.jeanlucmakiola.clockula/.ui.screensaver.ScreenSaverSettingsActivity" />
@@ -31,7 +31,7 @@ class ManifestRulesTest {
}
@Test
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() {
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers and the screen saver are exported`() {
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
.findAll(text)
.map { it.value }
@@ -47,7 +47,10 @@ class ManifestRulesTest {
// timer widgets' colour screens) are exported for the same reason —
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
// cross-package — and guarded to that one action by `the six
// widget configurators answer only APPWIDGET_CONFIGURE` below.
// widget configurators answer only APPWIDGET_CONFIGURE` below. The
// screen saver's dream is bound by the system's dream manager and its
// settings screen started by the Settings app, both from another
// process; `the screen saver is bound only by the system` guards them.
assertThat(exported).containsExactly(
".MainActivity",
".BloomIconAlias",
@@ -65,9 +68,31 @@ class ManifestRulesTest {
".widget.AnalogClockWidgetReceiver",
".widget.StopwatchWidgetReceiver",
".widget.QuickTimerWidgetReceiver",
".ui.screensaver.ClockulaDreamService",
".ui.screensaver.ScreenSaverSettingsActivity",
)
}
/**
* Issue #25: the dream answers only the platform's DreamService action and
* demands BIND_DREAM_SERVICE, which only the system holds; its settings
* screen answers no intent at all, so it is reachable only by name.
*/
@Test
fun `the screen saver is bound only by the system`() {
val dream = Regex("<service\\b[\\s\\S]*?</service>").findAll(text)
.map { it.value }
.first { it.contains("android:name=\".ui.screensaver.ClockulaDreamService\"") }
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(dream)
.map { it.groupValues[1] }
.toList()
assertThat(actions).containsExactly("android.service.dreams.DreamService")
assertThat(dream).contains("android:permission=\"android.permission.BIND_DREAM_SERVICE\"")
assertThat(activityBlock(".ui.screensaver.ScreenSaverSettingsActivity")).doesNotContain("<intent-filter")
assertThat(activityBlock(".ui.screensaver.ScreenSaverActivity")).contains("android:exported=\"false\"")
}
/**
* M11: the mirror of `the six widget receivers answer only
* APPWIDGET_UPDATE` for the six widget config activities — being
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
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.ThemeMode
import org.junit.jupiter.api.Test
@@ -62,6 +64,7 @@ class BackupCodecTest {
homeZoneId = "America/New_York",
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
),
)
@@ -263,6 +266,22 @@ class BackupCodecTest {
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
}
@Test
fun `a file from before the screen saver restores its defaults`() {
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings())
}
@Test
fun `an unrecognised screen saver style degrades rather than rejecting`() {
val json = minimalDocument().replace(
"\"settings\": {}",
"\"settings\": {\"screen_saver_style\": \"HOLOGRAM\", \"screen_saver_night_mode\": true}",
)
val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.DIGITAL, nightMode = true))
}
@Test
fun `an empty preset list is honoured as all removed`() {
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
@@ -8,6 +8,8 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
@@ -76,6 +78,7 @@ class SettingsPrefsTest {
dynamicColor = false,
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
),
)
@@ -85,6 +88,18 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
}
@Test
fun `the screen saver starts digital with night mode off, and both choices round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings())
prefs.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
prefs.setScreenSaverNightMode(true)
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
}
@Test
@@ -0,0 +1,80 @@
package de.jeanlucmakiola.clockula.domain.screensaver
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.math.abs
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** Issue #25: the clock moves once a minute, everywhere on screen, and stays put within the minute. */
class ScreenSaverDriftTest {
private val start = Instant.parse("2026-10-22T22:00:00Z")
/** A night's worth of minutes. */
private val night = (0 until 8 * 60).map { start + it.minutes }
@Test
fun `every position is inside the free space`() {
for (minute in night) {
val position = ScreenSaverDrift.positionAt(minute)
assertThat(position.x).isAtLeast(0f)
assertThat(position.x).isAtMost(1f)
assertThat(position.y).isAtLeast(0f)
assertThat(position.y).isAtMost(1f)
}
}
@Test
fun `the clock holds its spot for the whole minute`() {
val atStart = ScreenSaverDrift.positionAt(start)
assertThat(ScreenSaverDrift.positionAt(start + 1.seconds)).isEqualTo(atStart)
assertThat(ScreenSaverDrift.positionAt(start + 59.seconds)).isEqualTo(atStart)
}
@Test
fun `the clock moves when the minute changes`() {
val moves = night.zipWithNext().count { (a, b) ->
ScreenSaverDrift.positionAt(a) != ScreenSaverDrift.positionAt(b)
}
assertThat(moves).isEqualTo(night.size - 1)
}
@Test
fun `over a night the clock visits every part of the screen`() {
// A 3 x 3 grid over the free space: a drift that never left one band
// would burn that band in as surely as a clock that never moved.
val cells = night.map {
val position = ScreenSaverDrift.positionAt(it)
(position.x * 2.999f).toInt() to (position.y * 2.999f).toInt()
}.toSet()
assertThat(cells).hasSize(9)
}
@Test
fun `consecutive minutes are not walked in a line`() {
// A plain modulo would step the same distance every minute.
val steps = night.zipWithNext().map { (a, b) ->
ScreenSaverDrift.positionAt(b).x - ScreenSaverDrift.positionAt(a).x
}
val distinct = steps.map { (it * 100).toInt() }.toSet()
assertThat(distinct.size).isGreaterThan(50)
assertThat(steps.count { abs(it) > 0.2f }).isGreaterThan(night.size / 4)
}
@Test
fun `a minute before the epoch still lands on screen`() {
val position = ScreenSaverDrift.positionAt(Instant.fromEpochSeconds(-61))
assertThat(position.x).isAtLeast(0f)
assertThat(position.x).isAtMost(1f)
assertThat(position.y).isAtLeast(0f)
assertThat(position.y).isAtMost(1f)
assertThat(position).isEqualTo(ScreenSaverDrift.positionAt(Instant.fromEpochSeconds(-120)))
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.domain.time
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class MinuteBoundaryTest {
private val minute = Instant.parse("2026-10-22T22:15:00Z")
@Test
fun `mid-minute waits for the rest of it`() {
assertThat(MinuteBoundary.untilNext(minute + 20.seconds)).isEqualTo(40.seconds)
}
@Test
fun `just before the boundary waits only for the boundary`() {
assertThat(MinuteBoundary.untilNext(minute + 59_999.milliseconds)).isEqualTo(1.milliseconds)
}
@Test
fun `exactly on the boundary waits a whole minute, never zero`() {
assertThat(MinuteBoundary.untilNext(minute)).isEqualTo(1.minutes)
}
@Test
fun `before the epoch still counts to the next boundary`() {
assertThat(MinuteBoundary.untilNext(Instant.fromEpochMilliseconds(-1_000))).isEqualTo(1.seconds)
}
}
@@ -0,0 +1,138 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.HourFormat
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.NEW_YORK
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
import de.jeanlucmakiola.clockula.testing.instantAt
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import java.time.LocalDateTime
import kotlin.time.Duration.Companion.seconds
/**
* Issue #25: the screen saver's source over the **real** alarm engine and a
* real DataStore. It wakes on the minute and on a clock change, and nowhere
* else, so every test drives the scheduler by hand rather than letting it
* run to idle: an idle scheduler would skip through minute after minute of
* virtual time.
*/
class ScreenSaverSourceTest {
private class FakeClockChanges : ClockChanges {
val events = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
override fun changes(): Flow<Unit> = events
}
private class FakeHourFormat(var is24: Boolean = true) : HourFormat {
override fun is24Hour(): Boolean = is24
}
private class Fixture(val harness: AlarmEngineHarness) {
val changes = FakeClockChanges()
val hourFormat = FakeHourFormat()
val source = ScreenSaverSource(
settings = harness.settings,
alarmEngine = harness.engine,
wallClock = harness.wallClock,
zones = harness.zones,
clockChanges = changes,
hourFormat = hourFormat,
)
val states = mutableListOf<ScreenSaverUiState>()
}
/** Wednesday 10 June 2026, half a minute past five in Berlin. */
private val start = instantAt(BERLIN, "2026-06-10T05:00:30")
private fun TestScope.collecting(tempDir: Path): Fixture {
val fixture = Fixture(alarmEngineHarness(tempDir, now = start))
backgroundScope.launch { fixture.source.state().toList(fixture.states) }
runCurrent()
return fixture
}
@Test
fun `the first frame is the current minute, the settings and the next alarm`(@TempDir tempDir: Path) = runTest {
val fixture = collecting(tempDir)
fixture.harness.givenAlarm(id = 1L, hour = 7, minute = 15, label = "Up")
runCurrent()
val state = fixture.states.last()
assertThat(state.time).isEqualTo(LocalDateTime.parse("2026-06-10T05:00"))
assertThat(state.settings).isEqualTo(ScreenSaverSettings())
assertThat(state.use24Hour).isTrue()
assertThat(state.position).isEqualTo(ScreenSaverDrift.positionAt(start))
assertThat(state.nextAlarm.hasAlarm).isTrue()
assertThat(state.nextAlarm.hour).isEqualTo(7)
assertThat(state.nextAlarm.minute).isEqualTo(15)
assertThat(state.nextAlarm.dayOfWeek).isEqualTo(DayOfWeek.WEDNESDAY)
assertThat(state.nextAlarm.label).isEqualTo("Up")
}
@Test
fun `no alarm reads as no alarm`(@TempDir tempDir: Path) = runTest {
val fixture = collecting(tempDir)
assertThat(fixture.states.last().nextAlarm.hasAlarm).isFalse()
}
@Test
fun `it sleeps until the minute changes, then moves on to it`(@TempDir tempDir: Path) = runTest {
val fixture = collecting(tempDir)
val before = fixture.states.size
fixture.harness.wallClock.instant = start + 29.seconds
advanceTimeBy(29.seconds)
runCurrent()
assertThat(fixture.states).hasSize(before)
fixture.harness.wallClock.instant = start + 30.seconds
advanceTimeBy(1.seconds)
runCurrent()
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-10T05:01"))
assertThat(fixture.states.last().position).isEqualTo(ScreenSaverDrift.positionAt(start + 30.seconds))
}
@Test
fun `a clock change is picked up at once, not at the next minute`(@TempDir tempDir: Path) = runTest {
val fixture = collecting(tempDir)
fixture.harness.zones.zone = NEW_YORK
fixture.hourFormat.is24 = false
fixture.changes.events.tryEmit(Unit)
runCurrent()
// 05:00 in Berlin is 23:00 the evening before in New York.
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-09T23:00"))
assertThat(fixture.states.last().use24Hour).isFalse()
}
@Test
fun `a settings change redraws without waiting for the minute`(@TempDir tempDir: Path) = runTest {
val fixture = collecting(tempDir)
fixture.harness.settings.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
fixture.harness.settings.setScreenSaverNightMode(true)
runCurrent()
assertThat(fixture.states.last().settings)
.isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
}
}
+16 -4
View File
@@ -90,7 +90,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker` |
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker`, and for the screen saver `ClockChanges` (a time, zone or 12/24-hour change while collected), `HourFormat` and `MinuteBoundary` |
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
| `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals; the only file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) |
| `domain/text/` | `TextFolding` (M9): NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search |
@@ -98,6 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode) and `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone |
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
@@ -105,9 +106,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `ScreenSaverPrefs` |
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker`, `AndroidClockChanges` (a receiver for `TIME_SET`/`TIMEZONE_CHANGED` registered only while collected), `AndroidHourFormat` |
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
| `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks; no schema or version change), `BackupRepository(+Impl)`, mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy |
| `alarm/` | `AlarmEngine` and the four seams it talks to (`AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier`), plus `AlarmIntents` |
@@ -138,7 +139,8 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
| `ui/screensaver/` | the screen saver (issue #25), here rather than beside the widgets because both of its hosts host Compose: `ClockulaDreamService` (the system's dream; `DreamViewOwner` puts the lifecycle, view-model-store and saved-state owners a `ComposeView` needs on the dream window's decor view), `ScreenSaverActivity` (the same content full-screen, from the tabs' overflow menu, since an app cannot start a dream without a system permission), `ScreenSaverSource` (prefs + `AlarmEngine.upcoming()` + the clocks, waking once a minute on the minute and on a `ClockChanges`), the content (numerals or the world clock's `WorldClockFace` without its second hand, the date and the next alarm through `NextAlarmWidgetPresenter`), and the settings rows shared by Settings → Clock and `ScreenSaverSettingsActivity`, the system's gear. `ui/theme/ScreenSaverTheme.kt` is the dark scheme over black, dimmed towards black in night mode |
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from every tab's overflow menu (`ui/shell/ShellOverflowMenu`, which also opens the screen saver), with no owning tab of their own |
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
---
@@ -599,6 +601,7 @@ Four slices:
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
| Screen saver (issue #25) | `screen_saver_style`, `screen_saver_night_mode` | `ScreenSaverPrefs`, surfaced as `SettingsPrefs.screenSaver: Flow<ScreenSaverSettings>` and carried in the backup |
The stopwatch's *run* is a single record, so it lives in DataStore rather than
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
@@ -1043,6 +1046,15 @@ three widget receivers, each with **only** the platform-required
platform-delivered broadcast no other app can trigger by naming the action.
M10 adds no `<uses-permission>`.
The screen saver (issue #25) adds `ui.screensaver.ClockulaDreamService`,
exported with only the `android.service.dreams.DreamService` filter and
guarded by `BIND_DREAM_SERVICE`, which only the system holds; its
`android.service.dream` meta-data (`res/xml/screen_saver_info.xml`) names
`ScreenSaverSettingsActivity`, exported with no intent filter so the Settings
app can open it by name; and the unexported `ScreenSaverActivity`. No
`<uses-permission>` either: keeping the screen on is a window flag, not a
wake lock.
---
## 10. What is not built yet
+7 -2
View File
@@ -136,11 +136,16 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"default_timer_ringtone_uri": null,
"default_dismiss_challenge": "NONE",
"home_zone_id": null,
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
"screen_saver_style": "DIGITAL",
"screen_saver_night_mode": false
}
```
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
Field names mirror `ClockPrefs`'/`SettingsPrefs`'/`ScreenSaverPrefs`' own
on-disk keys. A file from before the screen saver has neither
`screen_saver_*` field and restores its defaults (digital, night mode off);
an unknown style degrades to `DIGITAL` rather than rejecting the file. Older
files may also carry `default_timer_duration_millis`, from a default timer
length setting that has since been removed; it is ignored on import like any
other unknown field. Every
+5 -2
View File
@@ -608,8 +608,11 @@ reliability soak across a few real nights. Then 1.0.0.
Deferred deliberately, in rough priority order:
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.
- ~~**Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
win, and the surface people look at most.~~ Done (issue #25):
`ClockulaDreamService` plus `ScreenSaverActivity` for the overflow menu's
"Screen saver", digital or analog, with a dim night mode and a once-a-minute
drift against burn-in (`ARCHITECTURE.md` §3, §6).
- **Quick Settings tile**: timer control and next alarm from the shade.
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
in its own right, realistically v2.