feat(screensaver): add a screen saver with a dim night mode
A DreamService shows a full-screen clock, the date and the next alarm, digital or as the world clock's analog face, on black. Night mode dims every colour towards black and turns the display down; the clock moves to a new spot each minute so nothing burns in. It wakes only on the minute and on a time, zone or 12/24-hour change. The tabs' settings gear becomes an overflow menu with "Screen saver" and "Settings". An app cannot start the system's dream without a system permission, so that item opens an activity with the same content, which keeps the screen on only while charging. Style and night mode live in Settings > Clock and in the dream's own settings screen, and are carried in backups; older backups restore the defaults. Fixes #25
This commit is contained in:
1 parent
3f431a7f32
commit
1e29226364
43 files changed
+1506
-48
No files matched your search
@@ -62,3 +62,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
analog and digital clocks that show your world clocks at larger sizes. Each
|
||||
widget can follow the system theme or stay light or dark, and choose what a
|
||||
tap opens.
|
||||
- **Screen saver.** A full-screen digital or analog clock with the date and
|
||||
your next alarm, for a phone on a charger or a dock. Night mode turns it
|
||||
very dim for a dark bedroom, and the clock moves a little every minute so
|
||||
nothing burns into the screen. Pick it in Android's screen saver settings,
|
||||
or open it any time from the menu on each tab. Its settings are in Settings
|
||||
→ Clock and are included in backups.
|
||||
@@ -273,6 +273,42 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The screen saver (issue #25). The system binds it on a charger or a
|
||||
dock once the user picks it in Settings → Display → Screen saver.
|
||||
Exported, since the dream manager is another process, and guarded
|
||||
by BIND_DREAM_SERVICE, a signature permission only the system
|
||||
holds, so nothing else can bind it. -->
|
||||
<service
|
||||
android:name=".ui.screensaver.ClockulaDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.dream"
|
||||
android:resource="@xml/screen_saver_info" />
|
||||
</service>
|
||||
|
||||
<!-- The screen saver's settings, opened by the gear beside it in the
|
||||
system's screen saver list. Exported because only an exported
|
||||
activity can be started by the Settings app; it reads no extras
|
||||
and changes nothing a user could not change in-app. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/screen_saver_title"
|
||||
android:theme="@style/Theme.Clockula" />
|
||||
|
||||
<!-- The same screen saver on demand, from the tabs' overflow menu: an
|
||||
app cannot start the system's dream itself. Not exported. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Clockula.ScreenSaver" />
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -103,5 +103,6 @@ class BackupEngine @Inject constructor(
|
||||
dynamicColor = settings.dynamicColor.first(),
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
screenSaver = settings.screenSaver.first(),
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
@@ -128,6 +130,8 @@ object BackupCodec {
|
||||
dismissChallenge = defaults.dismissChallenge.name,
|
||||
homeZoneId = defaults.homeZoneId,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
screenSaverStyle = screenSaver.style.name,
|
||||
screenSaverNightMode = screenSaver.nightMode,
|
||||
)
|
||||
|
||||
// --- decode + validate ---
|
||||
@@ -235,6 +239,10 @@ object BackupCodec {
|
||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
screenSaver = ScreenSaverSettings(
|
||||
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
|
||||
nightMode = settingsDto.screenSaverNightMode,
|
||||
),
|
||||
)
|
||||
|
||||
return BackupResult.Success(
|
||||
|
||||
@@ -77,4 +77,7 @@ data class SettingsDto(
|
||||
* built-in ones.
|
||||
*/
|
||||
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
|
||||
/** Absent in files from before the screen saver; they restore its defaults. */
|
||||
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
|
||||
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
|
||||
)
|
||||
@@ -5,12 +5,16 @@ import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat
|
||||
import de.jeanlucmakiola.clockula.data.time.RealTicker
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
@@ -39,4 +43,10 @@ abstract class TimeModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindTicker(impl: RealTicker): Ticker
|
||||
|
||||
@Binds
|
||||
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
|
||||
|
||||
@Binds
|
||||
abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
|
||||
/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */
|
||||
object ScreenSaverPrefs {
|
||||
|
||||
val style: Pref<ScreenSaverStyle> = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
|
||||
|
||||
val nightMode: Pref<Boolean> = booleanPref("screen_saver_night_mode", false)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.clockula.data.prefs
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
@@ -93,6 +95,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge
|
||||
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
|
||||
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +137,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
||||
}
|
||||
|
||||
// --- screen saver (issue #25) ---
|
||||
|
||||
/** Built once and composed from its two keys, for the same reasons [defaults] is. */
|
||||
val screenSaver: Flow<ScreenSaverSettings> = combine(
|
||||
store.flow(ScreenSaverPrefs.style),
|
||||
store.flow(ScreenSaverPrefs.nightMode),
|
||||
::ScreenSaverSettings,
|
||||
).distinctUntilChanged()
|
||||
|
||||
suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style)
|
||||
|
||||
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.clockula.data.time
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.text.format.DateFormat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver registered for as
|
||||
* long as the flow is collected — a readout on screen, never the manifest's
|
||||
* `SystemEventReceiver`, which repairs the alarm schedule and has no business
|
||||
* waking a screen that is not there.
|
||||
*/
|
||||
class AndroidClockChanges @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ClockChanges {
|
||||
|
||||
override fun changes(): Flow<Unit> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
}
|
||||
// Not exported: both are protected system broadcasts, which reach an
|
||||
// unexported receiver anyway, and nothing else should be able to.
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
|
||||
class AndroidHourFormat @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : HourFormat {
|
||||
override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
@@ -56,4 +57,5 @@ data class BackupSettings(
|
||||
val dynamicColor: Boolean,
|
||||
val defaults: ClockDefaults,
|
||||
val timerPresets: List<Duration>,
|
||||
val screenSaver: ScreenSaverSettings = ScreenSaverSettings(),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Where the screen saver's clock sits, so a face left up all night never
|
||||
* burns into an OLED panel: it moves to a new spot once a minute, as AOSP's
|
||||
* DeskClock screen saver does.
|
||||
*
|
||||
* A position is a pair of fractions of the *free* space — the screen minus
|
||||
* the clock itself — so `0f` is flush with the left/top edge and `1f` with
|
||||
* the right/bottom one, at any screen and clock size. Derived from the minute
|
||||
* alone rather than from a random source, so the clock lands on the same spot
|
||||
* however often the minute is re-read (a recomposition, a time change) and
|
||||
* moves only when the minute does.
|
||||
*/
|
||||
object ScreenSaverDrift {
|
||||
|
||||
data class Position(val x: Float, val y: Float)
|
||||
|
||||
/** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */
|
||||
fun positionAt(now: Instant): Position {
|
||||
val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE)
|
||||
val bits = mix(minute)
|
||||
return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16))
|
||||
}
|
||||
|
||||
/** The low 24 bits of [bits] as a fraction in `[0, 1]`. */
|
||||
private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24
|
||||
|
||||
/**
|
||||
* SplitMix64's finaliser: consecutive minutes land on unrelated spots,
|
||||
* where a plain modulo would walk the clock across the screen in a line.
|
||||
*/
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + GOLDEN_GAMMA
|
||||
z = (z xor (z ushr 30)) * MIX_1
|
||||
z = (z xor (z ushr 27)) * MIX_2
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MASK_24 = 0xFFFFFFL
|
||||
private const val GOLDEN_GAMMA = -0x61c8864680b583ebL
|
||||
private const val MIX_1 = -0x40a7b892e31b1a47L
|
||||
private const val MIX_2 = -0x6b2fb644ecceee15L
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
/** The screen saver's two faces: the time as numerals, or the world clock's dial. */
|
||||
enum class ScreenSaverStyle { DIGITAL, ANALOG }
|
||||
|
||||
/**
|
||||
* What the screen saver shows. [nightMode] is Google Clock's "very dim
|
||||
* display, for dark rooms": dim colours on black and the display itself
|
||||
* turned down, for a phone on a bedside table.
|
||||
*/
|
||||
data class ScreenSaverSettings(
|
||||
val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL,
|
||||
val nightMode: Boolean = false,
|
||||
)
|
||||
@@ -41,3 +41,18 @@ interface ZoneProvider {
|
||||
interface Ticker {
|
||||
fun ticks(period: Duration): Flow<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's clock being changed under a running readout: the time set by
|
||||
* hand or by the network, the zone, or the 12/24-hour choice, which the
|
||||
* platform announces as a time change too. Emits once per change while
|
||||
* collected. Injected, so a test hands the change over instead of making one.
|
||||
*/
|
||||
interface ClockChanges {
|
||||
fun changes(): Flow<Unit>
|
||||
}
|
||||
|
||||
/** The device's 12/24-hour choice, read afresh on every call. */
|
||||
interface HourFormat {
|
||||
fun is24Hour(): Boolean
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How long a minute-resolution readout may sleep: until the next wall-clock
|
||||
* minute begins, so it changes on the minute rather than up to a minute late,
|
||||
* and wakes once a minute rather than on a fixed sub-second cadence.
|
||||
*/
|
||||
object MinuteBoundary {
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
|
||||
/**
|
||||
* From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a
|
||||
* boundary is a whole minute away, never zero, so a caller looping on it
|
||||
* cannot spin.
|
||||
*/
|
||||
fun untilNext(now: Instant): Duration {
|
||||
val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE)
|
||||
return (MILLIS_PER_MINUTE - intoMinute).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
@@ -71,12 +70,7 @@ fun AlarmsScreen(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.service.dreams.DreamService
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Clockula's screen saver, as the system starts it — on a charger or a dock,
|
||||
* from Settings → Display → Screen saver. Non-interactive: any touch wakes
|
||||
* the device, as a screen saver should. Night mode turns the display down to
|
||||
* the system's dim level ([setScreenBright]) on top of the dimmed colours.
|
||||
*
|
||||
* Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its
|
||||
* lifecycle, view-model store and saved-state owners on the window's decor
|
||||
* view, where [DreamViewOwner] puts them before the view is attached.
|
||||
*
|
||||
* Its settings screen is [ScreenSaverSettingsActivity], named in
|
||||
* `res/xml/screen_saver_info.xml`.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ClockulaDreamService : DreamService() {
|
||||
|
||||
@Inject internal lateinit var source: ScreenSaverSource
|
||||
|
||||
private var owner: DreamViewOwner? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
isInteractive = false
|
||||
isFullscreen = true
|
||||
|
||||
val owner = DreamViewOwner().also { owner = it }
|
||||
owner.create(window.decorView)
|
||||
setContentView(
|
||||
ComposeView(this).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
|
||||
setContent {
|
||||
ScreenSaverRoot(source, onNightModeChanged = { night -> isScreenBright = !night })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
owner?.start()
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
owner?.stop()
|
||||
super.onDreamingStopped()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
owner?.destroy()
|
||||
owner = null
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
|
||||
/**
|
||||
* The lifecycle, view-model store and saved-state registry a `ComposeView`
|
||||
* expects to find on its window, for a window no `ComponentActivity` owns —
|
||||
* a `DreamService`'s. Without them the `ComposeView` throws on attach.
|
||||
*
|
||||
* The lifecycle follows the dream: created with its window, resumed while it
|
||||
* is dreaming, stopped when it stops, destroyed when the window goes. A
|
||||
* stopped lifecycle is what makes `collectAsStateWithLifecycle` let go of the
|
||||
* minute ticks, so a dream that is no longer shown costs nothing.
|
||||
*/
|
||||
internal class DreamViewOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val registry = LifecycleRegistry(this)
|
||||
private val savedState = SavedStateRegistryController.create(this)
|
||||
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
override val viewModelStore: ViewModelStore = ViewModelStore()
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
|
||||
|
||||
/** Restores (nothing — a dream keeps no state across windows) and moves to CREATED, then installs itself on [root]. */
|
||||
fun create(root: View) {
|
||||
savedState.performRestore(null)
|
||||
registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
root.setViewTreeLifecycleOwner(this)
|
||||
root.setViewTreeViewModelStoreOwner(this)
|
||||
root.setViewTreeSavedStateRegistryOwner(this)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
registry.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (registry.currentState.isAtLeast(Lifecycle.State.STARTED)) registry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (registry.currentState == Lifecycle.State.INITIALIZED) return
|
||||
registry.currentState = Lifecycle.State.DESTROYED
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The screen saver on demand, from the tabs' overflow menu. An app cannot
|
||||
* start the system's dream without a system permission, so this shows the
|
||||
* same [ScreenSaverRoot] full-screen instead, the way Google Clock's own
|
||||
* "Screen saver" item does.
|
||||
*
|
||||
* Any touch closes it, as it would end a dream. It keeps the screen on only
|
||||
* while the phone is charging: on battery the display times out as it
|
||||
* normally would, so a screen saver left open in a bag does not drain it.
|
||||
* Night mode turns the window's own brightness down to almost nothing.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ScreenSaverActivity : ComponentActivity() {
|
||||
|
||||
@Inject internal lateinit var source: ScreenSaverSource
|
||||
|
||||
private val powerReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
setContent { ScreenSaverRoot(source, onNightModeChanged = ::applyNightBrightness) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// ACTION_BATTERY_CHANGED is sticky: registering returns the current
|
||||
// state at once, then every change while the screen saver is shown.
|
||||
val sticky = ContextCompat.registerReceiver(
|
||||
this,
|
||||
powerReceiver,
|
||||
IntentFilter(Intent.ACTION_BATTERY_CHANGED),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
sticky?.let(::applyKeepScreenOn)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(powerReceiver)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun applyKeepScreenOn(battery: Intent) {
|
||||
val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|
||||
if (charging) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyNightBrightness(nightMode: Boolean) {
|
||||
window.attributes = window.attributes.apply {
|
||||
screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
|
||||
const val NIGHT_BRIGHTNESS = 0.01f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.DayNight
|
||||
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockFace
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The screen saver itself, as both hosts draw it: the time — numerals or the
|
||||
* world clock's dial — the date and the next alarm, on black, at a spot that
|
||||
* moves once a minute ([ScreenSaverDrift]) so nothing stays lit in one place.
|
||||
* The move is a slow crossfade rather than a slide, as DeskClock's is, and
|
||||
* a cut under reduce motion.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.displayCutout)
|
||||
.padding(ScreenSaverDefaults.Margin),
|
||||
) {
|
||||
// Sized from the screen's shorter side, so the clock is as large in
|
||||
// landscape on a bedside dock as it is upright in the hand.
|
||||
val side = minOf(maxWidth, maxHeight)
|
||||
Crossfade(
|
||||
targetState = state.position,
|
||||
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
|
||||
label = "drift",
|
||||
) { position ->
|
||||
Drifting(position) { Clock(state, side) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Places its one child at [position]'s fractions of the space the child leaves free. */
|
||||
@Composable
|
||||
private fun Drifting(position: ScreenSaverDrift.Position, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = Modifier.fillMaxSize()) { measurables, constraints ->
|
||||
val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.place(
|
||||
x = ((constraints.maxWidth - placeable.width).coerceAtLeast(0) * position.x).roundToInt(),
|
||||
y = ((constraints.maxHeight - placeable.height).coerceAtLeast(0) * position.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Clock(state: ScreenSaverUiState, side: Dp) {
|
||||
val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.ROOT
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
when (state.settings.style) {
|
||||
ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side)
|
||||
ScreenSaverStyle.ANALOG -> WorldClockFace(
|
||||
time = state.time.toLocalTime(),
|
||||
dayFraction = DayNight.fractionAt(state.time.toLocalTime()),
|
||||
modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION),
|
||||
showSecondHand = false,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(ScreenSaverDefaults.Gap))
|
||||
Text(
|
||||
text = remember(state.time.toLocalDate(), locale) { dateLabel(state.time, locale) },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.nextAlarm.hasAlarm) {
|
||||
Spacer(Modifier.height(ScreenSaverDefaults.Gap / 2))
|
||||
NextAlarm(state.nextAlarm, state.time, state.use24Hour, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The time in numerals, scaled to the screen. In 12-hour time the AM/PM
|
||||
* marker is kept — a bedside clock that cannot tell 7 in the morning from 7
|
||||
* at night is no use — but drawn small, where the locale's own pattern puts
|
||||
* it.
|
||||
*/
|
||||
@Composable
|
||||
private fun DigitalTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
|
||||
val parts = remember(time, use24Hour, locale) { timeParts(time, use24Hour, locale) }
|
||||
val digits = parts.before.length + parts.after.length
|
||||
// About 0.6em per tabular digit or colon, so the readout fills ~90 % of
|
||||
// the shorter side at any length.
|
||||
val size = minOf(side * 0.9f / (digits.coerceAtLeast(4) * 0.6f), side * 0.4f)
|
||||
val fontSize = with(LocalDensity.current) { size.toSp() }
|
||||
val text = buildAnnotatedString {
|
||||
append(parts.before)
|
||||
if (parts.marker.isNotEmpty()) {
|
||||
withStyle(SpanStyle(fontSize = fontSize * 0.3f)) { append(parts.marker) }
|
||||
}
|
||||
append(parts.after)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize,
|
||||
// Thin strokes: fewer lit pixels all night, and the look of a
|
||||
// bedside clock rather than a banner.
|
||||
fontWeight = FontWeight.Light,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
|
||||
val label = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmLabel(model, now, use24Hour, locale) }
|
||||
val description = stringResource(R.string.screen_saver_next_alarm, label)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Alarm,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A time split around its AM/PM marker; [marker] is empty in 24-hour time. */
|
||||
private data class TimeParts(val before: String, val marker: String, val after: String)
|
||||
|
||||
private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts {
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "Hmm" else "hmma")
|
||||
val markerAt = pattern.indexOf('a')
|
||||
if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
|
||||
return runCatching {
|
||||
TimeParts(
|
||||
before = format(time, pattern.substring(0, markerAt), locale),
|
||||
marker = format(time, "a", locale),
|
||||
after = format(time, pattern.substring(markerAt + 1), locale),
|
||||
)
|
||||
}.getOrElse { TimeParts(format(time, pattern, locale), "", "") }
|
||||
}
|
||||
|
||||
private fun format(time: LocalTime, pattern: String, locale: Locale): String =
|
||||
if (pattern.isBlank()) pattern else DateTimeFormatter.ofPattern(pattern, locale).format(time)
|
||||
|
||||
/** The locale's own full weekday-and-date form, "Thursday, October 22". */
|
||||
private fun dateLabel(now: LocalDateTime, locale: Locale): String =
|
||||
DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEEMMMMd"), locale).format(now)
|
||||
|
||||
/**
|
||||
* The next alarm as the platform's own screen saver writes it: the short
|
||||
* weekday and the time, "Tue 7:00 AM", in the locale's order. The weekday
|
||||
* is always there, so a time alone can never be read as today's.
|
||||
*/
|
||||
private fun nextAlarmLabel(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): String {
|
||||
val day = model.dayOfWeek ?: now.dayOfWeek
|
||||
val at = now.toLocalDate()
|
||||
.with(TemporalAdjusters.nextOrSame(day))
|
||||
.atTime(model.hour, model.minute)
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "EEEHmm" else "EEEhmma")
|
||||
return DateTimeFormatter.ofPattern(pattern, locale).format(at)
|
||||
}
|
||||
|
||||
private object ScreenSaverDefaults {
|
||||
/** Keeps the clock off the very edge, where a curved panel bends it away. */
|
||||
val Margin: Dp = 24.dp
|
||||
val Gap: Dp = 16.dp
|
||||
|
||||
/** The analog face's share of the screen's shorter side. */
|
||||
const val FACE_FRACTION = 0.55f
|
||||
|
||||
/** Slow enough to read as a drift, not a jump. */
|
||||
const val MOVE_MILLIS = 1_200
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
|
||||
/**
|
||||
* What both hosts set as their content: [source]'s state in the screen
|
||||
* saver's theme. Collected with the host's lifecycle, so a stopped dream or
|
||||
* a backgrounded activity stops waking for the minute as well.
|
||||
*
|
||||
* [onNightModeChanged] hands the night-mode choice back to the host, which
|
||||
* owns the window and so the display's brightness.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) {
|
||||
val appearance by source.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
|
||||
val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
|
||||
val nightMode = state?.settings?.nightMode ?: false
|
||||
LaunchedEffect(nightMode) { onNightModeChanged(nightMode) }
|
||||
|
||||
ScreenSaverTheme(nightMode = nightMode, dynamicColor = appearance.dynamicColor) {
|
||||
// Black until the first state lands, rather than a frame of anything else.
|
||||
state?.let { ScreenSaverContent(it) }
|
||||
?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The screen saver's settings as Android opens them: the gear next to
|
||||
* Clockula in Settings → Display → Screen saver (`android:settingsActivity`
|
||||
* in `res/xml/screen_saver_info.xml`). The same rows as Settings → Clock,
|
||||
* in the app's own theme.
|
||||
*
|
||||
* Exported because only an exported activity can be started by the Settings
|
||||
* app; it takes no extras and does nothing a user could not do in-app.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ScreenSaverSettingsActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
|
||||
ClockulaTheme(
|
||||
darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) {
|
||||
ScreenSaverSettingsSection(inApp = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The screen saver's settings as a run of grouped rows: its style, night
|
||||
* mode, and a way to see it now. [inApp] adds a last row into Android's own
|
||||
* screen saver settings, where Clockula is chosen and its start condition
|
||||
* set; the system's settings screen opens this one, so there it is left out.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverSettingsSection(
|
||||
inApp: Boolean,
|
||||
viewModel: ScreenSaverSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
val current = settings ?: return
|
||||
val context = LocalContext.current
|
||||
var showStyle by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_style),
|
||||
summary = styleLabel(current.style),
|
||||
position = Position.Top,
|
||||
onClick = { showStyle = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_night_mode),
|
||||
summary = stringResource(R.string.screen_saver_night_mode_hint),
|
||||
position = Position.Middle,
|
||||
trailing = { Switch(checked = current.nightMode, onCheckedChange = viewModel::onNightModeChanged) },
|
||||
onClick = { viewModel.onNightModeChanged(!current.nightMode) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_preview),
|
||||
position = if (inApp) Position.Middle else Position.Bottom,
|
||||
onClick = { openScreenSaver(context) },
|
||||
)
|
||||
if (inApp) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_system_settings),
|
||||
summary = stringResource(R.string.screen_saver_system_settings_hint),
|
||||
position = Position.Bottom,
|
||||
onClick = { openSystemScreenSaverSettings(context) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showStyle) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.screen_saver_style),
|
||||
options = ScreenSaverStyle.entries.toList(),
|
||||
selected = current.style,
|
||||
label = { styleLabel(it) },
|
||||
onSelect = viewModel::onStyleSelected,
|
||||
onDismiss = { showStyle = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun styleLabel(style: ScreenSaverStyle): String = stringResource(
|
||||
when (style) {
|
||||
ScreenSaverStyle.DIGITAL -> R.string.screen_saver_style_digital
|
||||
ScreenSaverStyle.ANALOG -> R.string.screen_saver_style_analog
|
||||
},
|
||||
)
|
||||
|
||||
/** The app's own full-screen screen saver ([ScreenSaverActivity]). */
|
||||
internal fun openScreenSaver(context: Context) {
|
||||
context.startActivity(Intent(context, ScreenSaverActivity::class.java))
|
||||
}
|
||||
|
||||
/** Android's screen saver settings. Some builds leave the screen out; then the row does nothing rather than crash. */
|
||||
private fun openSystemScreenSaverSettings(context: Context) {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_DREAM_SETTINGS)) }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The screen saver's two settings, for both places they are edited: Settings
|
||||
* → Clock, and the system's own screen saver settings ([ScreenSaverSettingsActivity]).
|
||||
* Every write lands at once, as everywhere else in Settings. Null until the
|
||||
* first read, so a row never shows a default it is about to change.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ScreenSaverSettingsViewModel @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<ScreenSaverSettings?> = settings.screenSaver
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), null)
|
||||
|
||||
fun onStyleSelected(style: ScreenSaverStyle) = viewModelScope.launch { settings.setScreenSaverStyle(style) }
|
||||
|
||||
fun onNightModeChanged(enabled: Boolean) = viewModelScope.launch { settings.setScreenSaverNightMode(enabled) }
|
||||
|
||||
private companion object {
|
||||
const val SUBSCRIPTION_GRACE_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.domain.time.MinuteBoundary
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
|
||||
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/** One frame of the screen saver: everything it draws, resolved to the minute. */
|
||||
internal data class ScreenSaverUiState(
|
||||
val settings: ScreenSaverSettings,
|
||||
/** The device's local time, truncated to the minute. */
|
||||
val time: LocalDateTime,
|
||||
val use24Hour: Boolean,
|
||||
val nextAlarm: NextAlarmWidgetModel,
|
||||
val position: ScreenSaverDrift.Position,
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefs + the alarm engine + the clocks → the screen saver's state, for both
|
||||
* of its hosts: the system's `ClockulaDreamService` and the app's own
|
||||
* `ScreenSaverActivity`.
|
||||
*
|
||||
* It wakes **once a minute**, on the minute ([MinuteBoundary]) — the face
|
||||
* shows no seconds, so a sub-second ticker would only burn battery on a
|
||||
* screen left up all night — and at once when the device's clock is changed
|
||||
* ([ClockChanges]: the time, the zone, or 12/24-hour). The next alarm is the
|
||||
* next-alarm widget's own model ([NextAlarmWidgetPresenter]), re-resolved on
|
||||
* every wake so an alarm that just rang moves on to its next occurrence.
|
||||
*/
|
||||
@Singleton
|
||||
internal class ScreenSaverSource @Inject constructor(
|
||||
private val settings: SettingsPrefs,
|
||||
private val alarmEngine: AlarmEngine,
|
||||
private val wallClock: WallClock,
|
||||
private val zones: ZoneProvider,
|
||||
private val clockChanges: ClockChanges,
|
||||
private val hourFormat: HourFormat,
|
||||
) {
|
||||
|
||||
val appearance: Flow<Appearance> = settings.appearance
|
||||
|
||||
fun state(): Flow<ScreenSaverUiState> =
|
||||
combine(settings.screenSaver, alarmEngine.upcoming(minuteTicks())) { screenSaver, upcoming ->
|
||||
val now = wallClock.now()
|
||||
val zone = zones.current()
|
||||
ScreenSaverUiState(
|
||||
settings = screenSaver,
|
||||
time = ZonedDateTime.ofInstant(now.toJavaInstant(), zone)
|
||||
.toLocalDateTime()
|
||||
.truncatedTo(ChronoUnit.MINUTES),
|
||||
use24Hour = hourFormat.is24Hour(),
|
||||
nextAlarm = NextAlarmWidgetPresenter.present(upcoming, now, zone),
|
||||
position = ScreenSaverDrift.positionAt(now),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Emits at once, then at every minute boundary. A clock change restarts
|
||||
* the wait, since a `delay` measured against the old time would land on
|
||||
* the wrong boundary.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun minuteTicks(): Flow<Unit> = clockChanges.changes()
|
||||
.onStart { emit(Unit) }
|
||||
.flatMapLatest {
|
||||
flow {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(MinuteBoundary.untilNext(wallClock.now()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsSection
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */
|
||||
/**
|
||||
* The clock tab's settings, and the screen saver's: it is a clock face, and
|
||||
* the settings Android's own screen saver screen opens are the same rows.
|
||||
* The zone picker itself is ViewModel state, drawn by the hub.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ClockSettingsScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
@@ -28,5 +34,9 @@ internal fun ClockSettingsScreen(
|
||||
position = Position.Alone,
|
||||
onClick = viewModel::onOpenHomeZonePicker,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.screen_saver_title))
|
||||
ScreenSaverSettingsSection(inApp = true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Nightlight
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.screensaver.openScreenSaver
|
||||
|
||||
/**
|
||||
* Every tab's top-bar overflow, as Google Clock's: the screen saver, then
|
||||
* Settings. The screen saver opens directly — it is an activity of its own,
|
||||
* not a destination in the shell's graph.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShellOverflowMenu(onOpenSettings: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.MoreVert,
|
||||
contentDescription = stringResource(R.string.menu_more_options),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.screen_saver_title)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Nightlight, contentDescription = null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
openScreenSaver(context)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_title)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenSettings()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
@@ -63,12 +60,7 @@ fun StopwatchScreen(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
|
||||
/**
|
||||
* The screen saver's theme: always Clockula's dark scheme — wallpaper colours
|
||||
* when [dynamicColor] — over true black, because the screen saver runs in a
|
||||
* dark room more often than not and black pixels on an OLED panel are off.
|
||||
*
|
||||
* [nightMode] dims every role the face draws with towards black, so the
|
||||
* faces keep their usual tokens (the analog face is the world clock's own
|
||||
* `WorldClockFace`) and simply come out very dim. The window's brightness is
|
||||
* turned down by the host as well; the two together are what makes the
|
||||
* night mode readable without lighting the room.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenSaverTheme(
|
||||
nightMode: Boolean,
|
||||
dynamicColor: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ClockulaTheme(darkTheme = true, dynamicColor = dynamicColor) {
|
||||
val base = MaterialTheme.colorScheme
|
||||
val scheme = remember(base, nightMode) { base.forScreenSaver(nightMode) }
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = scheme,
|
||||
typography = MaterialTheme.typography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
motionScheme = MaterialTheme.motionScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of each role survives night mode: enough to read across a dark bedroom, little enough not to light it. */
|
||||
private const val NIGHT_LEVEL = 0.32f
|
||||
|
||||
private fun ColorScheme.forScreenSaver(nightMode: Boolean): ColorScheme {
|
||||
val black = copy(background = Color.Black, surface = Color.Black)
|
||||
if (!nightMode) return black
|
||||
fun Color.dim(): Color = lerp(Color.Black, this, NIGHT_LEVEL)
|
||||
return black.copy(
|
||||
primary = primary.dim(),
|
||||
onSurface = onSurface.dim(),
|
||||
onSurfaceVariant = onSurfaceVariant.dim(),
|
||||
// The analog face's dial and marks.
|
||||
secondaryContainer = secondaryContainer.dim(),
|
||||
onSecondaryContainer = onSecondaryContainer.dim(),
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -25,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
|
||||
@@ -59,12 +58,7 @@ fun TimersScreen(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// Only over a list: on an empty tab the keypad is already on
|
||||
|
||||
@@ -42,6 +42,10 @@ import java.time.LocalTime
|
||||
* Scheme tokens only, no literal colour. The face is hidden from screen
|
||||
* readers: the time readout under it says the same time as text, so
|
||||
* describing the face as well would read the time twice.
|
||||
*
|
||||
* The screen saver reuses it at its own size (a [modifier] size wins over
|
||||
* the hero size) and without the second hand: it redraws once a minute, and
|
||||
* a second hand that only moved once a minute would be a broken one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -49,6 +53,7 @@ internal fun WorldClockFace(
|
||||
time: LocalTime,
|
||||
dayFraction: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
showSecondHand: Boolean = true,
|
||||
) {
|
||||
val dialColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
val markColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
@@ -82,7 +87,7 @@ internal fun WorldClockFace(
|
||||
) {
|
||||
drawDial(morph, progress, dialPath, dialMatrix, dialColor)
|
||||
drawTicks(markColor)
|
||||
drawHands(time, markColor, accentColor)
|
||||
drawHands(time, markColor, accentColor, showSecondHand)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +132,11 @@ private fun DrawScope.drawTicks(color: Color) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) {
|
||||
private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color, showSecondHand: Boolean) {
|
||||
val radius = size.minDimension / 2f
|
||||
hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor)
|
||||
hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor)
|
||||
hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
|
||||
if (showSecondHand) hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor)
|
||||
drawCircle(accentColor, radius = 4.dp.toPx(), center = center)
|
||||
drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx()))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -35,6 +34,7 @@ import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
@@ -87,12 +87,7 @@ fun WorldClockScreen(
|
||||
contentDescription = stringResource(R.string.world_clock_home_action),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val canAdd = loaded?.canAdd != false
|
||||
|
||||
@@ -301,9 +301,21 @@
|
||||
<string name="settings_timers_sound_subtitle">Sound for new timers</string>
|
||||
<string name="settings_timer_sound_hint">New timers ring with this sound. Each timer can still change it in its editor.</string>
|
||||
<string name="settings_section_clock">Clock</string>
|
||||
<string name="settings_clock_subtitle">Home time zone</string>
|
||||
<string name="settings_clock_subtitle">Home time zone and screen saver</string>
|
||||
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
|
||||
<string name="settings_home_zone">Home time zone</string>
|
||||
<string name="screen_saver_title">Screen saver</string>
|
||||
<string name="screen_saver_style">Clock style</string>
|
||||
<string name="screen_saver_style_digital">Digital</string>
|
||||
<string name="screen_saver_style_analog">Analog</string>
|
||||
<string name="screen_saver_night_mode">Night mode</string>
|
||||
<string name="screen_saver_night_mode_hint">Very dim display, for dark rooms</string>
|
||||
<string name="screen_saver_preview">Open screen saver</string>
|
||||
<string name="screen_saver_system_settings">Choose when it starts</string>
|
||||
<string name="screen_saver_system_settings_hint">Pick Clockula as the screen saver in Android\'s settings</string>
|
||||
<!-- Read by a screen reader for the alarm line on the screen saver; %1$s is the day and time, "Tue 7:00 AM". -->
|
||||
<string name="screen_saver_next_alarm">Next alarm: %1$s</string>
|
||||
<string name="menu_more_options">More options</string>
|
||||
<string name="settings_self_check">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_title">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
title-less so nothing flashes on the way through (M9 D3). -->
|
||||
<style name="Theme.Clockula.Invisible" parent="android:Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- The screen saver activity: black from the first frame, so opening it
|
||||
in a dark room never flashes the light window background. -->
|
||||
<style name="Theme.Clockula.ScreenSaver" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<!-- An hour numeral on the analog widget's dial (`layout/widget_analog_*.xml`).
|
||||
Its text size and inset are overridden per widget size at runtime. -->
|
||||
<style name="WidgetAnalogNumeral">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The screen saver's entry in Settings → Display → Screen saver: the gear
|
||||
beside it opens ScreenSaverSettingsActivity. -->
|
||||
<dream xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:settingsActivity="de.jeanlucmakiola.clockula/.ui.screensaver.ScreenSaverSettingsActivity" />
|
||||
@@ -31,7 +31,7 @@ class ManifestRulesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() {
|
||||
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers and the screen saver are exported`() {
|
||||
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
|
||||
.findAll(text)
|
||||
.map { it.value }
|
||||
@@ -47,7 +47,10 @@ class ManifestRulesTest {
|
||||
// timer widgets' colour screens) are exported for the same reason —
|
||||
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
|
||||
// cross-package — and guarded to that one action by `the six
|
||||
// widget configurators answer only APPWIDGET_CONFIGURE` below.
|
||||
// widget configurators answer only APPWIDGET_CONFIGURE` below. The
|
||||
// screen saver's dream is bound by the system's dream manager and its
|
||||
// settings screen started by the Settings app, both from another
|
||||
// process; `the screen saver is bound only by the system` guards them.
|
||||
assertThat(exported).containsExactly(
|
||||
".MainActivity",
|
||||
".BloomIconAlias",
|
||||
@@ -65,9 +68,31 @@ class ManifestRulesTest {
|
||||
".widget.AnalogClockWidgetReceiver",
|
||||
".widget.StopwatchWidgetReceiver",
|
||||
".widget.QuickTimerWidgetReceiver",
|
||||
".ui.screensaver.ClockulaDreamService",
|
||||
".ui.screensaver.ScreenSaverSettingsActivity",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #25: the dream answers only the platform's DreamService action and
|
||||
* demands BIND_DREAM_SERVICE, which only the system holds; its settings
|
||||
* screen answers no intent at all, so it is reachable only by name.
|
||||
*/
|
||||
@Test
|
||||
fun `the screen saver is bound only by the system`() {
|
||||
val dream = Regex("<service\\b[\\s\\S]*?</service>").findAll(text)
|
||||
.map { it.value }
|
||||
.first { it.contains("android:name=\".ui.screensaver.ClockulaDreamService\"") }
|
||||
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(dream)
|
||||
.map { it.groupValues[1] }
|
||||
.toList()
|
||||
|
||||
assertThat(actions).containsExactly("android.service.dreams.DreamService")
|
||||
assertThat(dream).contains("android:permission=\"android.permission.BIND_DREAM_SERVICE\"")
|
||||
assertThat(activityBlock(".ui.screensaver.ScreenSaverSettingsActivity")).doesNotContain("<intent-filter")
|
||||
assertThat(activityBlock(".ui.screensaver.ScreenSaverActivity")).contains("android:exported=\"false\"")
|
||||
}
|
||||
|
||||
/**
|
||||
* M11: the mirror of `the six widget receivers answer only
|
||||
* APPWIDGET_UPDATE` for the six widget config activities — being
|
||||
|
||||
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -62,6 +64,7 @@ class BackupCodecTest {
|
||||
homeZoneId = "America/New_York",
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -263,6 +266,22 @@ class BackupCodecTest {
|
||||
assertThat(settings.timerPresets).isEqualTo(TimerPresets.BUILT_IN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before the screen saver restores its defaults`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised screen saver style degrades rather than rejecting`() {
|
||||
val json = minimalDocument().replace(
|
||||
"\"settings\": {}",
|
||||
"\"settings\": {\"screen_saver_style\": \"HOLOGRAM\", \"screen_saver_night_mode\": true}",
|
||||
)
|
||||
val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings
|
||||
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.DIGITAL, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -8,6 +8,8 @@ import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
@@ -76,6 +78,7 @@ class SettingsPrefsTest {
|
||||
dynamicColor = false,
|
||||
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -85,6 +88,18 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen saver starts digital with night mode off, and both choices round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings())
|
||||
|
||||
prefs.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
|
||||
prefs.setScreenSaverNightMode(true)
|
||||
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Issue #25: the clock moves once a minute, everywhere on screen, and stays put within the minute. */
|
||||
class ScreenSaverDriftTest {
|
||||
|
||||
private val start = Instant.parse("2026-10-22T22:00:00Z")
|
||||
|
||||
/** A night's worth of minutes. */
|
||||
private val night = (0 until 8 * 60).map { start + it.minutes }
|
||||
|
||||
@Test
|
||||
fun `every position is inside the free space`() {
|
||||
for (minute in night) {
|
||||
val position = ScreenSaverDrift.positionAt(minute)
|
||||
assertThat(position.x).isAtLeast(0f)
|
||||
assertThat(position.x).isAtMost(1f)
|
||||
assertThat(position.y).isAtLeast(0f)
|
||||
assertThat(position.y).isAtMost(1f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the clock holds its spot for the whole minute`() {
|
||||
val atStart = ScreenSaverDrift.positionAt(start)
|
||||
|
||||
assertThat(ScreenSaverDrift.positionAt(start + 1.seconds)).isEqualTo(atStart)
|
||||
assertThat(ScreenSaverDrift.positionAt(start + 59.seconds)).isEqualTo(atStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the clock moves when the minute changes`() {
|
||||
val moves = night.zipWithNext().count { (a, b) ->
|
||||
ScreenSaverDrift.positionAt(a) != ScreenSaverDrift.positionAt(b)
|
||||
}
|
||||
|
||||
assertThat(moves).isEqualTo(night.size - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `over a night the clock visits every part of the screen`() {
|
||||
// A 3 x 3 grid over the free space: a drift that never left one band
|
||||
// would burn that band in as surely as a clock that never moved.
|
||||
val cells = night.map {
|
||||
val position = ScreenSaverDrift.positionAt(it)
|
||||
(position.x * 2.999f).toInt() to (position.y * 2.999f).toInt()
|
||||
}.toSet()
|
||||
|
||||
assertThat(cells).hasSize(9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive minutes are not walked in a line`() {
|
||||
// A plain modulo would step the same distance every minute.
|
||||
val steps = night.zipWithNext().map { (a, b) ->
|
||||
ScreenSaverDrift.positionAt(b).x - ScreenSaverDrift.positionAt(a).x
|
||||
}
|
||||
val distinct = steps.map { (it * 100).toInt() }.toSet()
|
||||
|
||||
assertThat(distinct.size).isGreaterThan(50)
|
||||
assertThat(steps.count { abs(it) > 0.2f }).isGreaterThan(night.size / 4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a minute before the epoch still lands on screen`() {
|
||||
val position = ScreenSaverDrift.positionAt(Instant.fromEpochSeconds(-61))
|
||||
|
||||
assertThat(position.x).isAtLeast(0f)
|
||||
assertThat(position.x).isAtMost(1f)
|
||||
assertThat(position.y).isAtLeast(0f)
|
||||
assertThat(position.y).isAtMost(1f)
|
||||
assertThat(position).isEqualTo(ScreenSaverDrift.positionAt(Instant.fromEpochSeconds(-120)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
class MinuteBoundaryTest {
|
||||
|
||||
private val minute = Instant.parse("2026-10-22T22:15:00Z")
|
||||
|
||||
@Test
|
||||
fun `mid-minute waits for the rest of it`() {
|
||||
assertThat(MinuteBoundary.untilNext(minute + 20.seconds)).isEqualTo(40.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `just before the boundary waits only for the boundary`() {
|
||||
assertThat(MinuteBoundary.untilNext(minute + 59_999.milliseconds)).isEqualTo(1.milliseconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly on the boundary waits a whole minute, never zero`() {
|
||||
assertThat(MinuteBoundary.untilNext(minute)).isEqualTo(1.minutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `before the epoch still counts to the next boundary`() {
|
||||
assertThat(MinuteBoundary.untilNext(Instant.fromEpochMilliseconds(-1_000))).isEqualTo(1.seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.NEW_YORK
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Issue #25: the screen saver's source over the **real** alarm engine and a
|
||||
* real DataStore. It wakes on the minute and on a clock change, and nowhere
|
||||
* else, so every test drives the scheduler by hand rather than letting it
|
||||
* run to idle: an idle scheduler would skip through minute after minute of
|
||||
* virtual time.
|
||||
*/
|
||||
class ScreenSaverSourceTest {
|
||||
|
||||
private class FakeClockChanges : ClockChanges {
|
||||
val events = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
override fun changes(): Flow<Unit> = events
|
||||
}
|
||||
|
||||
private class FakeHourFormat(var is24: Boolean = true) : HourFormat {
|
||||
override fun is24Hour(): Boolean = is24
|
||||
}
|
||||
|
||||
private class Fixture(val harness: AlarmEngineHarness) {
|
||||
val changes = FakeClockChanges()
|
||||
val hourFormat = FakeHourFormat()
|
||||
val source = ScreenSaverSource(
|
||||
settings = harness.settings,
|
||||
alarmEngine = harness.engine,
|
||||
wallClock = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
clockChanges = changes,
|
||||
hourFormat = hourFormat,
|
||||
)
|
||||
val states = mutableListOf<ScreenSaverUiState>()
|
||||
}
|
||||
|
||||
/** Wednesday 10 June 2026, half a minute past five in Berlin. */
|
||||
private val start = instantAt(BERLIN, "2026-06-10T05:00:30")
|
||||
|
||||
private fun TestScope.collecting(tempDir: Path): Fixture {
|
||||
val fixture = Fixture(alarmEngineHarness(tempDir, now = start))
|
||||
backgroundScope.launch { fixture.source.state().toList(fixture.states) }
|
||||
runCurrent()
|
||||
return fixture
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first frame is the current minute, the settings and the next alarm`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
fixture.harness.givenAlarm(id = 1L, hour = 7, minute = 15, label = "Up")
|
||||
runCurrent()
|
||||
|
||||
val state = fixture.states.last()
|
||||
assertThat(state.time).isEqualTo(LocalDateTime.parse("2026-06-10T05:00"))
|
||||
assertThat(state.settings).isEqualTo(ScreenSaverSettings())
|
||||
assertThat(state.use24Hour).isTrue()
|
||||
assertThat(state.position).isEqualTo(ScreenSaverDrift.positionAt(start))
|
||||
assertThat(state.nextAlarm.hasAlarm).isTrue()
|
||||
assertThat(state.nextAlarm.hour).isEqualTo(7)
|
||||
assertThat(state.nextAlarm.minute).isEqualTo(15)
|
||||
assertThat(state.nextAlarm.dayOfWeek).isEqualTo(DayOfWeek.WEDNESDAY)
|
||||
assertThat(state.nextAlarm.label).isEqualTo("Up")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no alarm reads as no alarm`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
assertThat(fixture.states.last().nextAlarm.hasAlarm).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it sleeps until the minute changes, then moves on to it`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
val before = fixture.states.size
|
||||
|
||||
fixture.harness.wallClock.instant = start + 29.seconds
|
||||
advanceTimeBy(29.seconds)
|
||||
runCurrent()
|
||||
assertThat(fixture.states).hasSize(before)
|
||||
|
||||
fixture.harness.wallClock.instant = start + 30.seconds
|
||||
advanceTimeBy(1.seconds)
|
||||
runCurrent()
|
||||
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-10T05:01"))
|
||||
assertThat(fixture.states.last().position).isEqualTo(ScreenSaverDrift.positionAt(start + 30.seconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock change is picked up at once, not at the next minute`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
fixture.harness.zones.zone = NEW_YORK
|
||||
fixture.hourFormat.is24 = false
|
||||
fixture.changes.events.tryEmit(Unit)
|
||||
runCurrent()
|
||||
|
||||
// 05:00 in Berlin is 23:00 the evening before in New York.
|
||||
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-09T23:00"))
|
||||
assertThat(fixture.states.last().use24Hour).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settings change redraws without waiting for the minute`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
fixture.harness.settings.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
|
||||
fixture.harness.settings.setScreenSaverNightMode(true)
|
||||
runCurrent()
|
||||
|
||||
assertThat(fixture.states.last().settings)
|
||||
.isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -90,7 +90,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
||||
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
|
||||
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker` |
|
||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker`, and for the screen saver `ClockChanges` (a time, zone or 12/24-hour change while collected), `HourFormat` and `MinuteBoundary` |
|
||||
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone`, `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
|
||||
| `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals; the only file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) |
|
||||
| `domain/text/` | `TextFolding` (M9): NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search |
|
||||
@@ -98,6 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
|
||||
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
|
||||
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
|
||||
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode) and `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone |
|
||||
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
|
||||
| `data/db/` | `ClockulaDatabase` (`@Database` v3, `exportSchema = true`) and `Migrations` |
|
||||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||||
@@ -105,9 +106,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
|
||||
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
|
||||
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `ScreenSaverPrefs` |
|
||||
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the device's alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker`, `AndroidClockChanges` (a receiver for `TIME_SET`/`TIMEZONE_CHANGED` registered only while collected), `AndroidHourFormat` |
|
||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
|
||||
| `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks; no schema or version change), `BackupRepository(+Impl)`, mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy |
|
||||
| `alarm/` | `AlarmEngine` and the four seams it talks to (`AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier`), plus `AlarmIntents` |
|
||||
@@ -138,7 +139,8 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
|
||||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
|
||||
| `ui/screensaver/` | the screen saver (issue #25), here rather than beside the widgets because both of its hosts host Compose: `ClockulaDreamService` (the system's dream; `DreamViewOwner` puts the lifecycle, view-model-store and saved-state owners a `ComposeView` needs on the dream window's decor view), `ScreenSaverActivity` (the same content full-screen, from the tabs' overflow menu, since an app cannot start a dream without a system permission), `ScreenSaverSource` (prefs + `AlarmEngine.upcoming()` + the clocks, waking once a minute on the minute and on a `ClockChanges`), the content (numerals or the world clock's `WorldClockFace` without its second hand, the date and the next alarm through `NextAlarmWidgetPresenter`), and the settings rows shared by Settings → Clock and `ScreenSaverSettingsActivity`, the system's gear. `ui/theme/ScreenSaverTheme.kt` is the dark scheme over black, dimmed towards black in night mode |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from every tab's overflow menu (`ui/shell/ShellOverflowMenu`, which also opens the screen saver), with no owning tab of their own |
|
||||
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
|
||||
|
||||
---
|
||||
@@ -599,6 +601,7 @@ Four slices:
|
||||
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
| Screen saver (issue #25) | `screen_saver_style`, `screen_saver_night_mode` | `ScreenSaverPrefs`, surfaced as `SettingsPrefs.screenSaver: Flow<ScreenSaverSettings>` and carried in the backup |
|
||||
|
||||
The stopwatch's *run* is a single record, so it lives in DataStore rather than
|
||||
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
|
||||
@@ -1043,6 +1046,15 @@ three widget receivers, each with **only** the platform-required
|
||||
platform-delivered broadcast no other app can trigger by naming the action.
|
||||
M10 adds no `<uses-permission>`.
|
||||
|
||||
The screen saver (issue #25) adds `ui.screensaver.ClockulaDreamService`,
|
||||
exported with only the `android.service.dreams.DreamService` filter and
|
||||
guarded by `BIND_DREAM_SERVICE`, which only the system holds; its
|
||||
`android.service.dream` meta-data (`res/xml/screen_saver_info.xml`) names
|
||||
`ScreenSaverSettingsActivity`, exported with no intent filter so the Settings
|
||||
app can open it by name; and the unexported `ScreenSaverActivity`. No
|
||||
`<uses-permission>` either: keeping the screen on is a window flag, not a
|
||||
wake lock.
|
||||
|
||||
---
|
||||
|
||||
## 10. What is not built yet
|
||||
|
||||
+7
-2
@@ -136,11 +136,16 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_timer_ringtone_uri": null,
|
||||
"default_dismiss_challenge": "NONE",
|
||||
"home_zone_id": null,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000]
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"screen_saver_style": "DIGITAL",
|
||||
"screen_saver_night_mode": false
|
||||
}
|
||||
```
|
||||
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`'/`ScreenSaverPrefs`' own
|
||||
on-disk keys. A file from before the screen saver has neither
|
||||
`screen_saver_*` field and restores its defaults (digital, night mode off);
|
||||
an unknown style degrades to `DIGITAL` rather than rejecting the file. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
other unknown field. Every
|
||||
|
||||
+5
-2
@@ -608,8 +608,11 @@ reliability soak across a few real nights. Then 1.0.0.
|
||||
|
||||
Deferred deliberately, in rough priority order:
|
||||
|
||||
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.
|
||||
- ~~**Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.~~ Done (issue #25):
|
||||
`ClockulaDreamService` plus `ScreenSaverActivity` for the overflow menu's
|
||||
"Screen saver", digital or analog, with a dim night mode and a once-a-minute
|
||||
drift against burn-in (`ARCHITECTURE.md` §3, §6).
|
||||
- **Quick Settings tile**: timer control and next alarm from the shade.
|
||||
- **Bedtime / sleep schedule**: wind-down and wake, with DND handoff. A feature
|
||||
in its own right, realistically v2.
|
||||
|
||||
Reference in new issue
Block a user