Merge branch 'feat/25-screen-saver' into release/v1.0.0

# Conflicts:
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt
#	docs/ARCHITECTURE.md
#	docs/BACKUP.md
This commit is contained in:
2026-10-26 20:59:33 +01:00
46 changed files with 1662 additions and 57 deletions
+6
View File
@@ -103,3 +103,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
analog and digital clocks that show your world clocks at larger sizes. Each
widget can follow the system theme or stay light or dark, and choose what a
tap opens.
- **Screen saver.** A full-screen digital or analog clock with the date and
your next alarm, for a phone on a charger or a dock. Night mode turns it
very dim for a dark bedroom, and the clock moves a little every minute so
nothing burns into the screen. Pick it in Android's screen saver settings,
or open it any time from the menu on each tab. Its settings are in Settings
→ Clock and are included in backups.
+45
View File
@@ -285,6 +285,51 @@
android:name=".bedtime.receiver.BedtimeReceiver"
android:exported="false" />
<!-- 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. A task of its
own and out of recents, so opening it from Settings never lands
in, or clears, the app's own task. -->
<activity
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="@string/screen_saver_title"
android:taskAffinity=""
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; it
finishes itself once no longer shown. Rotating it into a stand,
docking it, or the dark-theme schedule firing re-lays it out
rather than recreating it, which would flash black and reset its
brightness. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|fontScale|locale|layoutDirection"
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
@@ -116,6 +116,7 @@ class BackupEngine @Inject constructor(
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
worldClockSeconds = settings.worldClockSeconds.first(),
screenSaver = settings.screenSaver.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -17,6 +17,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
@@ -147,6 +149,8 @@ object BackupCodec {
worldClockSeconds = worldClockSeconds,
timerPresets = timerPresets.map { it.inWholeMilliseconds },
bedtime = bedtime?.toDto(),
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
)
private fun BedtimeSettings.toDto() = BedtimeDto(
@@ -305,6 +309,10 @@ object BackupCodec {
),
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
worldClockSeconds = settingsDto.worldClockSeconds,
screenSaver = ScreenSaverSettings(
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
nightMode = settingsDto.screenSaverNightMode,
),
bedtime = settingsDto.bedtime?.toBedtime(),
)
@@ -105,6 +105,9 @@ data class SettingsDto(
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
val bedtime: BedtimeDto? = null,
/** Absent in files from before the screen saver; they restore its defaults. */
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
)
/**
@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat
import de.jeanlucmakiola.clockula.data.time.RealTicker
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.HourFormat
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
@@ -44,4 +46,7 @@ abstract class TimeModule {
@Binds
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
@Binds
abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
}
@@ -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)
}
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
@@ -129,6 +131,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
}
}
@@ -271,6 +275,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
}
// --- screen saver (issue #25) ---
/** Built once and composed from its two keys, for the same reasons [defaults] is. */
val screenSaver: Flow<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) ---
/**
@@ -4,9 +4,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.text.format.DateFormat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
import de.jeanlucmakiola.clockula.domain.time.HourFormat
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -40,3 +42,10 @@ class AndroidClockChanges @Inject constructor(
awaitClose { context.unregisterReceiver(receiver) }
}
}
/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
class AndroidHourFormat @Inject constructor(
@ApplicationContext private val context: Context,
) : HourFormat {
override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
}
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import java.time.LocalDate
import kotlin.time.Duration
@@ -63,6 +64,7 @@ data class BackupSettings(
val timerPresets: List<Duration>,
/** The world clock's "Display time with seconds" (#23). */
val worldClockSeconds: Boolean,
val screenSaver: ScreenSaverSettings,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.domain.screensaver
import java.time.LocalDate
/**
* How the screen saver names the day of the next alarm. A weekday alone
* ("Thu") is only unambiguous for the coming six days: a weekly alarm whose
* time has passed today next rings in seven, and a skipped one in fourteen,
* and either would read as today's. Those carry their date as well.
*/
object ScreenSaverAlarmDay {
private const val DAYS_A_WEEKDAY_NAMES = 7L
fun weekdayIsEnough(fireDate: LocalDate, today: LocalDate): Boolean =
fireDate.isBefore(today.plusDays(DAYS_A_WEEKDAY_NAMES))
}
@@ -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,
)
@@ -51,11 +51,16 @@ interface Ticker {
}
/**
* The system saying the wall clock may not read what a readout last drew: the
* time was set or the zone changed. A readout on a long cadence restarts its
* tick on this, so it does not wait out its own period to catch up. Emits
* only while collected.
* The device's clock being changed under a running readout: the time set by
* hand or by the network, the zone, or the 12/24-hour choice, which the
* platform announces as a time change too. Emits once per change while
* collected. Injected, so a test hands the change over instead of making one.
*/
interface ClockChanges {
fun changes(): Flow<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
}
}
@@ -16,10 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -35,6 +33,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -78,12 +77,7 @@ fun AlarmsScreen(
title = stringResource(R.string.tab_alarms),
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
floatingActionButton = {
FloatingActionButton(
@@ -6,9 +6,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.floret.locale.currentLocale
import java.time.LocalTime
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
/**
* Honours the user's 12/24-hour setting. Deliberately not unit-tested: it is
@@ -27,20 +31,29 @@ internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTim
// The locale can change under a running activity, and the 12/24-hour
// choice rides the same configuration, so both are re-read with it.
val configuration = LocalConfiguration.current
val formatter = remember(configuration, withSeconds) {
// The device's 12/24-hour choice, then the locale's own short form —
// the same two-step the ring notification already formats with.
if (DateFormat.is24HourFormat(context)) {
DateTimeFormatter.ofPattern(if (withSeconds) "HH:mm:ss" else "HH:mm")
} else {
DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT)
}
val locale = currentLocale()
val formatter = remember(configuration, locale, withSeconds) {
DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale, withSeconds), locale)
}
return remember(formatter) {
{ time -> formatter.format(time) }
}
}
/**
* The rule itself, as a pattern: the device's 12/24-hour choice, then the
* locale's own short form — the same two-step the ring notification already
* formats with. A pattern rather than a formatter for the screen saver, which
* splits it around the AM/PM marker to draw that smaller.
*/
internal fun localTimePattern(is24Hour: Boolean, locale: Locale, withSeconds: Boolean = false): String =
if (is24Hour) {
if (withSeconds) "HH:mm:ss" else "HH:mm"
} else {
val style = if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, style, IsoChronology.INSTANCE, locale)
}
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
@Composable
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
@@ -0,0 +1,71 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.service.dreams.DreamService
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Clockula's screen saver, as the system starts it — on a charger or a dock,
* from Settings → Display → Screen saver. Non-interactive: any touch wakes
* the device, as a screen saver should. Night mode turns the display down to
* the system's dim level ([setScreenBright]) and the window's own
* brightness to the same near-zero the in-app host uses, on top of the
* dimmed colours.
*
* Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its
* lifecycle, view-model store and saved-state owners on the window's decor
* view, where [DreamViewOwner] puts them before the view is attached.
*
* Its settings screen is [ScreenSaverSettingsActivity], named in
* `res/xml/screen_saver_info.xml`.
*/
@AndroidEntryPoint
class ClockulaDreamService : DreamService() {
@Inject internal lateinit var source: ScreenSaverSource
private var owner: DreamViewOwner? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isInteractive = false
isFullscreen = true
// Dim until the settings say otherwise (ScreenSaverRoot).
isScreenBright = false
window.applyScreenSaverBrightness(nightMode = true)
val owner = DreamViewOwner().also { owner = it }
owner.create(window.decorView)
setContentView(
// The window's context, not the service's: a Service is not a UI
// context, and the window's knows its display's density and size.
ComposeView(window.context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
setContent {
ScreenSaverRoot(source, onNightModeChanged = { night ->
isScreenBright = !night
window.applyScreenSaverBrightness(night)
})
}
},
)
}
override fun onDreamingStarted() {
super.onDreamingStarted()
owner?.start()
}
override fun onDreamingStopped() {
owner?.stop()
super.onDreamingStopped()
}
override fun onDetachedFromWindow() {
owner?.destroy()
owner = null
super.onDetachedFromWindow()
}
}
@@ -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,98 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* The screen saver on demand, from the tabs' overflow menu. An app cannot
* start the system's dream without a system permission, so this shows the
* same [ScreenSaverRoot] full-screen instead, the way Google Clock's own
* "Screen saver" item does.
*
* Any touch closes it, as it would end a dream. It keeps the screen on only
* while the phone is charging: on battery the display times out as it
* normally would, so a screen saver left open in a bag does not drain it.
* Kept on or not, the phone still locks on its usual timeout
* (`FLAG_ALLOW_LOCK_WHILE_SCREEN_ON`, as DeskClock's screen saver does) and
* the screen saver carries on over the lock screen, so a phone left on the
* charger overnight is not left unlocked; a touch then lands on the keyguard.
* Night mode turns the window's own brightness down to almost nothing; the
* window starts that way, until the settings are known.
*/
@AndroidEntryPoint
class ScreenSaverActivity : ComponentActivity() {
@Inject internal lateinit var source: ScreenSaverSource
private val powerReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent)
}
/** The flag last applied; null until the first battery state. Changing a window flag costs a relayout, and the battery reports far more often than the plug changes. */
private var keepingScreenOn: Boolean? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
window.applyScreenSaverBrightness(nightMode = true)
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent { ScreenSaverRoot(source, onNightModeChanged = window::applyScreenSaverBrightness) }
}
override fun onStart() {
super.onStart()
// ACTION_BATTERY_CHANGED is sticky: registering returns the current
// state at once, then every change while the screen saver is shown.
val sticky = ContextCompat.registerReceiver(
this,
powerReceiver,
IntentFilter(Intent.ACTION_BATTERY_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
sticky?.let(::applyKeepScreenOn)
}
override fun onStop() {
unregisterReceiver(powerReceiver)
super.onStop()
// Gone once it is no longer shown — Home, Recents, or the screen
// timing out on battery — as a dream ends, so the next wake shows the
// lock screen and the next launch the app, never a screen saver left
// behind. A rotation recreates nothing (configChanges in the manifest).
if (!isChangingConfigurations) finish()
}
override fun onUserInteraction() {
super.onUserInteraction()
finish()
}
private fun applyKeepScreenOn(battery: Intent) {
val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
if (charging == keepingScreenOn) return
keepingScreenOn = charging
if (charging) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
@@ -0,0 +1,305 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverAlarmDay
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
import de.jeanlucmakiola.clockula.domain.worldclock.DayNight
import de.jeanlucmakiola.clockula.ui.common.localTimePattern
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockFace
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.Locale
import kotlin.math.roundToInt
/**
* The screen saver itself, as both hosts draw it: the time — numerals or the
* world clock's dial — the date and the next alarm, on black, at a spot that
* moves once a minute ([ScreenSaverDrift]) so nothing stays lit in one place.
* The move is a slow crossfade rather than a slide, as DeskClock's is, and
* a cut under reduce motion: the old minute fades out where it was as the
* new one fades in at its new spot. Only a move fades: a new next alarm or
* a settings change redraws in place.
*/
@Composable
internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
val reduceMotion = rememberReduceMotion()
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.displayCutout)
.padding(ScreenSaverDefaults.Margin),
) {
// Sized from the screen's shorter side, so the clock is as large in
// landscape on a bedside dock as it is upright in the hand.
val side = minOf(maxWidth, maxHeight)
// The last frame drawn at each spot, so the outgoing copy keeps its
// own minute while it fades. Two are all a crossfade ever shows.
val frames = remember { RecentFrames() }
frames.record(state)
Crossfade(
targetState = state.position,
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
label = "drift",
) { position ->
val current = position == state.position
val shown = if (current) state else frames[position] ?: state
// The outgoing minute is only a picture while it fades: a screen
// reader finds one time on screen, never two.
val semantics = if (current) Modifier else Modifier.clearAndSetSemantics {}
Drifting(position, semantics) { Clock(shown, side) }
}
}
}
/** The newest two frames by position; older ones have finished fading. */
private class RecentFrames : LinkedHashMap<ScreenSaverDrift.Position, ScreenSaverUiState>() {
/**
* Re-inserted, not just replaced, so a spot the clock returns to (the
* time set back a minute) counts as newest and the frame fading out is
* never the one evicted.
*/
fun record(state: ScreenSaverUiState) {
remove(state.position)
put(state.position, state)
}
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ScreenSaverDrift.Position, ScreenSaverUiState>?): Boolean =
size > 2
}
/** Places its one child at [position]'s fractions of the space the child leaves free. */
@Composable
private fun Drifting(position: ScreenSaverDrift.Position, modifier: Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier.fillMaxSize()) { measurables, constraints ->
val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0))
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(
x = ((constraints.maxWidth - placeable.width).coerceAtLeast(0) * position.x).roundToInt(),
y = ((constraints.maxHeight - placeable.height).coerceAtLeast(0) * position.y).roundToInt(),
)
}
}
}
@Composable
private fun Clock(state: ScreenSaverUiState, side: Dp) {
val locale = currentLocale()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
when (state.settings.style) {
ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side)
ScreenSaverStyle.ANALOG -> AnalogTime(state.time.toLocalTime(), state.use24Hour, locale, side)
}
Spacer(Modifier.height(ScreenSaverDefaults.Gap))
Text(
text = remember(state.time.toLocalDate(), locale) { dateLabel(state.time, locale) },
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.nextAlarm.hasAlarm) {
Spacer(Modifier.height(ScreenSaverDefaults.Gap / 2))
NextAlarm(state.nextAlarm, state.time, state.use24Hour, locale)
}
}
}
/**
* The time in numerals, scaled to the screen. In 12-hour time the AM/PM
* marker is kept — a bedside clock that cannot tell 7 in the morning from 7
* at night is no use — but drawn small, where the locale's own pattern puts
* it.
*/
@Composable
private fun DigitalTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
val parts = remember(time, use24Hour, locale) { timeParts(time, use24Hour, locale) }
// About 0.6em per tabular digit or colon, the marker at its own smaller
// size, so the readout spans DIGITS_FRACTION of the shorter side at any
// length — never all of it, or upright on a phone there would be no room
// left across the screen to drift into.
val ems = (parts.before.length + parts.after.length + parts.marker.length * MARKER_SCALE)
.coerceAtLeast(4f) * 0.6f
val size = minOf(side * DIGITS_FRACTION / ems, side * 0.32f)
val fontSize = with(LocalDensity.current) { size.toSp() }
val text = buildAnnotatedString {
append(parts.before)
if (parts.marker.isNotEmpty()) {
withStyle(SpanStyle(fontSize = fontSize * MARKER_SCALE)) { append(parts.marker) }
}
append(parts.after)
}
Text(
text = text,
style = MaterialTheme.typography.displayLarge.copy(
fontSize = fontSize,
lineHeight = fontSize,
// Thin strokes: fewer lit pixels all night, and the look of a
// bedside clock rather than a banner.
fontWeight = FontWeight.Light,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
softWrap = false,
)
}
/**
* The world clock's own face, without its second hand (the screen saver
* redraws once a minute). The face hides itself from screen readers because
* on the World clock tab a text readout sits under it; here nothing does, so
* the face is given the time as its description.
*/
@Composable
private fun AnalogTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
val description = remember(time, use24Hour, locale) { timeFormatter(use24Hour, locale).format(time) }
Box(Modifier.semantics { contentDescription = description }) {
WorldClockFace(
time = time,
dayFraction = DayNight.fractionAt(time),
modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION),
showSecondHand = false,
)
}
}
@Composable
private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
val (day, time) = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmParts(model, now, use24Hour, locale) }
val label = stringResource(R.string.screen_saver_next_alarm_when, day, time)
val description = stringResource(R.string.screen_saver_next_alarm, label)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
) {
Icon(
imageVector = Icons.Rounded.Alarm,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
/** The AM/PM marker's size against the digits'. */
private const val MARKER_SCALE = 0.3f
/** The digital readout's share of the screen's shorter side. */
private const val DIGITS_FRACTION = 0.7f
/** The app's one 12/24-hour rule ([localTimePattern]), so the screen saver never writes a time the alarm list writes differently. */
private fun timeFormatter(use24Hour: Boolean, locale: Locale): DateTimeFormatter =
DateTimeFormatter.ofPattern(localTimePattern(use24Hour, locale), locale)
/** A time split around its AM/PM marker; [marker] is empty in 24-hour time. */
private data class TimeParts(val before: String, val marker: String, val after: String)
private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts {
val pattern = localTimePattern(use24Hour, locale)
val markerAt = markerIndex(pattern)
if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
return runCatching {
TimeParts(
before = format(time, pattern.substring(0, markerAt), locale),
marker = format(time, "a", locale),
after = format(time, pattern.substring(markerAt + 1), locale),
)
}.getOrElse { TimeParts(format(time, pattern, locale), "", "") }
}
/** Where the pattern's AM/PM field is, skipping quoted literal text; -1 when it has none. */
private fun markerIndex(pattern: String): Int {
var quoted = false
pattern.forEachIndexed { index, char ->
when {
char == '\'' -> quoted = !quoted
!quoted && char == 'a' -> return index
}
}
return -1
}
private fun format(time: LocalTime, pattern: String, locale: Locale): String =
if (pattern.isBlank()) pattern else DateTimeFormatter.ofPattern(pattern, locale).format(time)
/** The locale's own full weekday-and-date form, "Thursday, October 22". */
private fun dateLabel(now: LocalDateTime, locale: Locale): String =
localizedDateFormatter(locale, "EEEEMMMMd").format(now)
/**
* The next alarm as the platform's own screen saver writes it: the short
* weekday and the time, "Tue 7:00 AM". The weekday is always there, so a
* time alone can never be read as today's, and an alarm a week or more away
* carries its date too ([ScreenSaverAlarmDay]), "Thu, Oct 29 7:00 AM". The
* time follows the app's one 12/24-hour rule, like the big readout above it;
* the two are joined by a translatable string, so a language can reorder them.
*/
private fun nextAlarmParts(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): Pair<String, String> {
// No date means the fire is within the coming week, on its weekday.
val today = now.toLocalDate()
val date = model.date ?: model.dayOfWeek?.let { today.with(TemporalAdjusters.nextOrSame(it)) } ?: today
val skeleton = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
return localizedDateFormatter(locale, skeleton).format(date) to
timeFormatter(use24Hour, locale).format(LocalTime.of(model.hour, model.minute))
}
private object ScreenSaverDefaults {
/** Keeps the clock off the very edge, where a curved panel bends it away. */
val Margin: Dp = 24.dp
val Gap: Dp = 16.dp
/** The analog face's share of the screen's shorter side. */
const val FACE_FRACTION = 0.55f
/** Slow enough to read as a drift, not a jump. */
const val MOVE_MILLIS = 1_200
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.view.Window
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme
import de.jeanlucmakiola.floret.prefs.Appearance
/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
private const val NIGHT_BRIGHTNESS = 0.01f
/**
* Both hosts' display brightness for [nightMode]: almost nothing at night,
* the user's own otherwise. The same window setting for the dream and the
* activity, so the system's screen saver is no brighter at night than the
* one opened from the menu.
*/
internal fun Window.applyScreenSaverBrightness(nightMode: Boolean) {
attributes = attributes.apply {
screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
}
/**
* What both hosts set as their content: [source]'s state in the screen
* saver's theme. Collected with the host's lifecycle, so a stopped dream or
* a backgrounded activity stops waking for the minute as well.
*
* [onNightModeChanged] hands the night-mode choice back to the host, which
* owns the window and so the display's brightness — only once it is known:
* both hosts start dim, so a night-mode screen saver never lights a dark room
* for the moment its settings take to load. Until the settings and the
* appearance have both arrived the screen stays black, rather than showing a
* frame in colours that are about to change.
*/
@Composable
internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) {
val appearance by source.appearance.collectAsStateWithLifecycle<Appearance?>(initialValue = null)
val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
val nightMode = state?.settings?.nightMode
LaunchedEffect(nightMode) { nightMode?.let(onNightModeChanged) }
ScreenSaverTheme(nightMode = nightMode ?: true, dynamicColor = appearance?.dynamicColor ?: false) {
val ready = state
if (ready != null && appearance != null) {
ScreenSaverContent(ready)
} else {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
}
}
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.isDark
import javax.inject.Inject
/**
* The screen saver's settings as Android opens them: the gear next to
* Clockula in Settings → Display → Screen saver (`android:settingsActivity`
* in `res/xml/screen_saver_info.xml`). The same rows as Settings → Clock,
* in the app's own theme.
*
* Exported because only an exported activity can be started by the Settings
* app; it takes no extras and does nothing a user could not do in-app.
*/
@AndroidEntryPoint
class ScreenSaverSettingsActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
// The bars follow the app's resolved theme, not the device's
// night flag, as MainActivity's do.
LaunchedEffect(darkTheme) {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
)
}
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) {
ScreenSaverSettingsSection(inApp = false)
}
}
}
}
}
@@ -0,0 +1,105 @@
package de.jeanlucmakiola.clockula.ui.screensaver
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
/**
* The screen saver's settings as a run of grouped rows: its style, night
* mode, and a way to see it now. [inApp] — Settings → Clock — puts them
* under their own header, and adds a last row into Android's own screen
* saver settings, where Clockula is chosen and its start condition set; the
* system's settings screen opens this one, so there both are left out.
* Nothing is drawn, header included, until the settings have been read.
*/
@Composable
internal fun ScreenSaverSettingsSection(
inApp: Boolean,
viewModel: ScreenSaverSettingsViewModel = hiltViewModel(),
) {
val settings by viewModel.state.collectAsStateWithLifecycle()
val current = settings ?: return
val context = LocalContext.current
var showStyle by rememberSaveable { mutableStateOf(false) }
if (inApp) {
Spacer(Modifier.height(16.dp))
GroupedSectionHeader(stringResource(R.string.screen_saver_title))
}
GroupedRow(
title = stringResource(R.string.screen_saver_style),
summary = styleLabel(current.style),
position = Position.Top,
onClick = { showStyle = true },
)
GroupedRow(
title = stringResource(R.string.screen_saver_night_mode),
summary = stringResource(R.string.screen_saver_night_mode_hint),
position = Position.Middle,
trailing = { Switch(checked = current.nightMode, onCheckedChange = viewModel::onNightModeChanged) },
onClick = { viewModel.onNightModeChanged(!current.nightMode) },
)
GroupedRow(
title = stringResource(R.string.screen_saver_preview),
position = if (inApp) Position.Middle else Position.Bottom,
onClick = { openScreenSaver(context) },
)
if (inApp) {
GroupedRow(
title = stringResource(R.string.screen_saver_system_settings),
summary = stringResource(R.string.screen_saver_system_settings_hint),
position = Position.Bottom,
onClick = { openSystemScreenSaverSettings(context) },
)
}
if (showStyle) {
OptionPicker(
title = stringResource(R.string.screen_saver_style),
options = ScreenSaverStyle.entries.toList(),
selected = current.style,
label = { styleLabel(it) },
onSelect = viewModel::onStyleSelected,
onDismiss = { showStyle = false },
)
}
}
@Composable
private fun styleLabel(style: ScreenSaverStyle): String = stringResource(
when (style) {
ScreenSaverStyle.DIGITAL -> R.string.screen_saver_style_digital
ScreenSaverStyle.ANALOG -> R.string.screen_saver_style_analog
},
)
/** The app's own full-screen screen saver ([ScreenSaverActivity]). */
internal fun openScreenSaver(context: Context) {
context.startActivity(Intent(context, ScreenSaverActivity::class.java))
}
/** Android's screen saver settings. Some builds leave the screen out; then the row does nothing rather than crash. */
private fun openSystemScreenSaverSettings(context: Context) {
runCatching { context.startActivity(Intent(Settings.ACTION_DREAM_SETTINGS)) }
}
@@ -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()))
}
}
}
}
@@ -8,12 +8,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsSection
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/**
* The clock tab's settings: the home zone, and whether the tab shows seconds.
* The clock tab's settings: the home zone, whether the tab shows seconds and
* the automatic local clock. Then the screen saver's: it is a clock face, and
* the settings Android's own screen saver screen opens are the same rows.
* The zone picker itself is ViewModel state, drawn by the hub.
*/
@Composable
@@ -68,5 +71,7 @@ internal fun ClockSettingsScreen(
},
onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) },
)
ScreenSaverSettingsSection(inApp = true)
}
}
@@ -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
@@ -44,6 +44,10 @@ import java.time.LocalTime
* Scheme tokens only, no literal colour. The face is hidden from screen
* readers: the time readout under it says the same time as text, so
* describing the face as well would read the time twice.
*
* The screen saver reuses it at its own size (a [modifier] size wins over
* the hero size) and without the second hand: it redraws once a minute, and
* a second hand that only moved once a minute would be a broken one.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -10,7 +10,6 @@ import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -41,6 +40,7 @@ import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
@@ -102,12 +102,7 @@ fun WorldClockScreen(
contentDescription = stringResource(R.string.world_clock_home_action),
)
}
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
ShellOverflowMenu(onOpenSettings = onOpenSettings)
},
floatingActionButton = {
val canAdd = loaded?.canAdd != false
+15 -1
View File
@@ -352,7 +352,7 @@
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone, seconds and local clock</string>
<string name="settings_clock_subtitle">Home time zone, seconds, local clock 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="settings_world_clock_seconds">Display time with seconds</string>
@@ -360,6 +360,20 @@
<string name="settings_automatic_local_clock">Automatic local clock</string>
<string name="settings_automatic_local_clock_hint">While you’re somewhere the time differs from home, show the local time under the home clock.</string>
<string name="settings_automatic_local_clock_needs_home">Needs a home time zone. Home follows the device now, so it’s always local time.</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>
<!-- The screen saver's next-alarm line: %1$s is the day ("Tue", or "Thu, Oct 29" a week or more away), %2$s the time ("7:00 AM"). -->
<string name="screen_saver_next_alarm_when">%1$s %2$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,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The screen saver's entry in Settings → Display → Screen saver: the gear
beside it opens ScreenSaverSettingsActivity. The class name alone, with
no "package/" prefix, so Settings pairs it with the installed build's own
package (the debug and releaseTest builds add an applicationId suffix). -->
<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
@@ -15,6 +15,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
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
@@ -76,6 +78,7 @@ class BackupCodecTest {
),
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
worldClockSeconds = true,
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
bedtime = BedtimeSettings(
enabled = true,
bedtime = TimeOfDay(23, 15),
@@ -112,6 +115,7 @@ class BackupCodecTest {
defaults = ClockDefaults(),
timerPresets = emptyList(),
worldClockSeconds = false,
screenSaver = ScreenSaverSettings(),
),
)
val decoded = BackupCodec.decode(BackupCodec.encode(original))
@@ -488,6 +492,22 @@ class BackupCodecTest {
assertThat(BackupCodec.encode(sampleContent())).contains("\"automatic_local_clock\": false")
}
@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\": []}")
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
@@ -95,6 +97,7 @@ class SettingsPrefsTest {
),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
worldClockSeconds = false,
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
),
)
@@ -110,6 +113,18 @@ class SettingsPrefsTest {
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
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,28 @@
package de.jeanlucmakiola.clockula.domain.screensaver
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
class ScreenSaverAlarmDayTest {
/** A Thursday. */
private val today = LocalDate.parse("2026-10-22")
@Test
fun `today and the next six days are named by their weekday alone`() {
for (days in 0L..6L) {
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(days), today)).isTrue()
}
}
@Test
fun `a week away is the same weekday as today, so it carries its date`() {
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(7), today)).isFalse()
}
@Test
fun `a skipped weekly alarm two weeks away carries its date`() {
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(14), today)).isFalse()
}
}
@@ -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,140 @@
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.ExperimentalCoroutinesApi
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.
*/
@OptIn(ExperimentalCoroutinesApi::class)
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` + `LocalClock` (when the device's zone gets a temporary row), `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 |
@@ -99,6 +99,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `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/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode), `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone, and `ScreenSaverAlarmDay`, which dates a next alarm a week or more away rather than naming its weekday 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` v4, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
@@ -106,9 +107,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`, `BedtimePrefs` (issue #1) |
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1), `ScreenSaverPrefs` (issue #25) |
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the bundled and device 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` |
@@ -140,8 +141,9 @@ 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/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/bedtime/` | `BedtimeScreen`/`BedtimeViewModel` (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
| `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/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` |
---
@@ -618,6 +620,7 @@ Six slices:
| 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` |
| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow<BedtimeSettings>` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
| 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.
@@ -1064,6 +1067,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
@@ -172,7 +172,9 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
"dim_wallpaper": true,
"dark_theme": true,
"hide_always_on_display": false
}
},
"screen_saver_style": "DIGITAL",
"screen_saver_night_mode": false
}
```
@@ -186,7 +188,10 @@ is clamped rather than rejected: times into a day, the reminder into 0–120
minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it;
the importing device creates its own (`docs/ARCHITECTURE.md` §18).
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. `volume_buttons_while_ringing` is one of
+5 -2
View File
@@ -611,8 +611,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** (issue #1): a first version exists as an
opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb