From 1e292263645451ee2e9221054657e63f1b8a43b7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 22 Oct 2026 08:07:44 +0200 Subject: [PATCH 1/7] 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 --- CHANGELOG.md | 6 + app/src/main/AndroidManifest.xml | 36 +++ .../clockula/backup/BackupEngine.kt | 1 + .../clockula/data/backup/BackupCodec.kt | 8 + .../clockula/data/backup/BackupDto.kt | 3 + .../clockula/data/di/TimeModule.kt | 10 + .../clockula/data/prefs/ScreenSaverPrefs.kt | 14 ++ .../clockula/data/prefs/SettingsPrefs.kt | 17 ++ .../clockula/data/time/AndroidClockChanges.kt | 49 ++++ .../clockula/domain/backup/BackupContent.kt | 2 + .../domain/screensaver/ScreenSaverDrift.kt | 47 ++++ .../domain/screensaver/ScreenSaverSettings.kt | 14 ++ .../clockula/domain/time/Clocks.kt | 15 ++ .../clockula/domain/time/MinuteBoundary.kt | 25 ++ .../clockula/ui/alarms/AlarmsScreen.kt | 10 +- .../ui/screensaver/ClockulaDreamService.kt | 61 +++++ .../clockula/ui/screensaver/DreamViewOwner.kt | 57 +++++ .../ui/screensaver/ScreenSaverActivity.kt | 91 +++++++ .../ui/screensaver/ScreenSaverContent.kt | 232 ++++++++++++++++++ .../ui/screensaver/ScreenSaverRoot.kt | 36 +++ .../ScreenSaverSettingsActivity.kt | 49 ++++ .../screensaver/ScreenSaverSettingsSection.kt | 93 +++++++ .../ScreenSaverSettingsViewModel.kt | 36 +++ .../ui/screensaver/ScreenSaverSource.kt | 95 +++++++ .../ui/settings/ClockSettingsScreen.kt | 12 +- .../clockula/ui/shell/ShellOverflowMenu.kt | 58 +++++ .../clockula/ui/stopwatch/StopwatchScreen.kt | 12 +- .../clockula/ui/theme/ScreenSaverTheme.kt | 58 +++++ .../clockula/ui/timers/TimersScreen.kt | 10 +- .../clockula/ui/worldclock/WorldClockFace.kt | 11 +- .../ui/worldclock/WorldClockScreen.kt | 9 +- app/src/main/res/values/strings.xml | 14 +- app/src/main/res/values/themes.xml | 8 + app/src/main/res/xml/screen_saver_info.xml | 5 + .../clockula/ManifestRulesTest.kt | 29 ++- .../clockula/data/backup/BackupCodecTest.kt | 19 ++ .../clockula/data/prefs/SettingsPrefsTest.kt | 15 ++ .../screensaver/ScreenSaverDriftTest.kt | 80 ++++++ .../domain/time/MinuteBoundaryTest.kt | 33 +++ .../ui/screensaver/ScreenSaverSourceTest.kt | 138 +++++++++++ docs/ARCHITECTURE.md | 20 +- docs/BACKUP.md | 9 +- docs/ROADMAP.md | 7 +- 43 files changed, 1506 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt create mode 100644 app/src/main/res/xml/screen_saver_info.xml create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDriftTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundaryTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSourceTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d4d0d..bc7a4a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,3 +62,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. analog and digital clocks that show your world clocks at larger sizes. Each widget can follow the system theme or stay light or dark, and choose what a tap opens. +- **Screen saver.** A full-screen digital or analog clock with the date and + your next alarm, for a phone on a charger or a dock. Night mode turns it + very dim for a dark bedroom, and the clock moves a little every minute so + nothing burns into the screen. Pick it in Android's screen saver settings, + or open it any time from the menu on each tab. Its settings are in Settings + → Clock and are included in backups. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cfe76b4..a3e159b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -273,6 +273,42 @@ + + + + + + + + + + + + + + + ? = null, + /** Absent in files from before the screen saver; they restore its defaults. */ + @SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL", + @SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index afd6f72..970af0b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -5,12 +5,16 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider +import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges +import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat import de.jeanlucmakiola.clockula.data.time.RealTicker import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock import de.jeanlucmakiola.clockula.data.time.SystemWallClock import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider import de.jeanlucmakiola.clockula.domain.time.BootIdProvider +import de.jeanlucmakiola.clockula.domain.time.ClockChanges import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.HourFormat import de.jeanlucmakiola.clockula.domain.time.Ticker import de.jeanlucmakiola.clockula.domain.time.WallClock import de.jeanlucmakiola.clockula.domain.time.ZoneProvider @@ -39,4 +43,10 @@ abstract class TimeModule { @Binds abstract fun bindTicker(impl: RealTicker): Ticker + + @Binds + abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges + + @Binds + abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt new file mode 100644 index 0000000..0b9c32a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.booleanPref +import de.jeanlucmakiola.floret.prefs.enumPref + +/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */ +object ScreenSaverPrefs { + + val style: Pref = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL) + + val nightMode: Pref = booleanPref("screen_saver_night_mode", false) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index b76785b..3da2fcb 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -3,6 +3,8 @@ package de.jeanlucmakiola.clockula.data.prefs import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.backup.BackupSettings +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle import de.jeanlucmakiola.clockula.domain.timer.TimerPresets import de.jeanlucmakiola.floret.prefs.Appearance import de.jeanlucmakiola.floret.prefs.AppearancePrefs @@ -93,6 +95,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { prefs[ClockPrefs.dismissChallenge] = backup.defaults.dismissChallenge prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId prefs[TimerPrefs.presets] = backup.timerPresets + prefs[ScreenSaverPrefs.style] = backup.screenSaver.style + prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode } } @@ -133,6 +137,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) } } + // --- screen saver (issue #25) --- + + /** Built once and composed from its two keys, for the same reasons [defaults] is. */ + val screenSaver: Flow = combine( + store.flow(ScreenSaverPrefs.style), + store.flow(ScreenSaverPrefs.nightMode), + ::ScreenSaverSettings, + ).distinctUntilChanged() + + suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style) + + suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled) + // --- first-launch bookkeeping (M4) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt new file mode 100644 index 0000000..4d47d47 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.clockula.data.time + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.text.format.DateFormat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.domain.time.ClockChanges +import de.jeanlucmakiola.clockula.domain.time.HourFormat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +/** + * `TIME_SET` and `TIMEZONE_CHANGED`, through a receiver registered for as + * long as the flow is collected — a readout on screen, never the manifest's + * `SystemEventReceiver`, which repairs the alarm schedule and has no business + * waking a screen that is not there. + */ +class AndroidClockChanges @Inject constructor( + @ApplicationContext private val context: Context, +) : ClockChanges { + + override fun changes(): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + } + // Not exported: both are protected system broadcasts, which reach an + // unexported receiver anyway, and nothing else should be able to. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { context.unregisterReceiver(receiver) } + } +} + +/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */ +class AndroidHourFormat @Inject constructor( + @ApplicationContext private val context: Context, +) : HourFormat { + override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index eac440d..79ff6a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.TimeOfDay +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings import de.jeanlucmakiola.floret.prefs.ThemeMode import kotlin.time.Duration import kotlin.time.Instant @@ -56,4 +57,5 @@ data class BackupSettings( val dynamicColor: Boolean, val defaults: ClockDefaults, val timerPresets: List, + val screenSaver: ScreenSaverSettings = ScreenSaverSettings(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt new file mode 100644 index 0000000..f92c1d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.clockula.domain.screensaver + +import kotlin.time.Instant + +/** + * Where the screen saver's clock sits, so a face left up all night never + * burns into an OLED panel: it moves to a new spot once a minute, as AOSP's + * DeskClock screen saver does. + * + * A position is a pair of fractions of the *free* space — the screen minus + * the clock itself — so `0f` is flush with the left/top edge and `1f` with + * the right/bottom one, at any screen and clock size. Derived from the minute + * alone rather than from a random source, so the clock lands on the same spot + * however often the minute is re-read (a recomposition, a time change) and + * moves only when the minute does. + */ +object ScreenSaverDrift { + + data class Position(val x: Float, val y: Float) + + /** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */ + fun positionAt(now: Instant): Position { + val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE) + val bits = mix(minute) + return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16)) + } + + /** The low 24 bits of [bits] as a fraction in `[0, 1]`. */ + private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24 + + /** + * SplitMix64's finaliser: consecutive minutes land on unrelated spots, + * where a plain modulo would walk the clock across the screen in a line. + */ + private fun mix(seed: Long): Long { + var z = seed + GOLDEN_GAMMA + z = (z xor (z ushr 30)) * MIX_1 + z = (z xor (z ushr 27)) * MIX_2 + return z xor (z ushr 31) + } + + private const val SECONDS_PER_MINUTE = 60L + private const val MASK_24 = 0xFFFFFFL + private const val GOLDEN_GAMMA = -0x61c8864680b583ebL + private const val MIX_1 = -0x40a7b892e31b1a47L + private const val MIX_2 = -0x6b2fb644ecceee15L +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt new file mode 100644 index 0000000..2ff1cdb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.clockula.domain.screensaver + +/** The screen saver's two faces: the time as numerals, or the world clock's dial. */ +enum class ScreenSaverStyle { DIGITAL, ANALOG } + +/** + * What the screen saver shows. [nightMode] is Google Clock's "very dim + * display, for dark rooms": dim colours on black and the display itself + * turned down, for a phone on a bedside table. + */ +data class ScreenSaverSettings( + val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL, + val nightMode: Boolean = false, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index e943df4..9908927 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -41,3 +41,18 @@ interface ZoneProvider { interface Ticker { fun ticks(period: Duration): Flow } + +/** + * The device's clock being changed under a running readout: the time set by + * hand or by the network, the zone, or the 12/24-hour choice, which the + * platform announces as a time change too. Emits once per change while + * collected. Injected, so a test hands the change over instead of making one. + */ +interface ClockChanges { + fun changes(): Flow +} + +/** The device's 12/24-hour choice, read afresh on every call. */ +interface HourFormat { + fun is24Hour(): Boolean +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt new file mode 100644 index 0000000..e6bb542 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.clockula.domain.time + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * How long a minute-resolution readout may sleep: until the next wall-clock + * minute begins, so it changes on the minute rather than up to a minute late, + * and wakes once a minute rather than on a fixed sub-second cadence. + */ +object MinuteBoundary { + + private const val MILLIS_PER_MINUTE = 60_000L + + /** + * From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a + * boundary is a whole minute away, never zero, so a caller looping on it + * cannot spin. + */ + fun untilNext(now: Instant): Duration { + val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE) + return (MILLIS_PER_MINUTE - intoMinute).milliseconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index bdb6c1d..9622bac 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +26,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedSurface @@ -71,12 +70,7 @@ fun AlarmsScreen( modifier = modifier, scrollable = false, actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, floatingActionButton = { FloatingActionButton( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt new file mode 100644 index 0000000..25c09d3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.service.dreams.DreamService +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * Clockula's screen saver, as the system starts it — on a charger or a dock, + * from Settings → Display → Screen saver. Non-interactive: any touch wakes + * the device, as a screen saver should. Night mode turns the display down to + * the system's dim level ([setScreenBright]) on top of the dimmed colours. + * + * Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its + * lifecycle, view-model store and saved-state owners on the window's decor + * view, where [DreamViewOwner] puts them before the view is attached. + * + * Its settings screen is [ScreenSaverSettingsActivity], named in + * `res/xml/screen_saver_info.xml`. + */ +@AndroidEntryPoint +class ClockulaDreamService : DreamService() { + + @Inject internal lateinit var source: ScreenSaverSource + + private var owner: DreamViewOwner? = null + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + isInteractive = false + isFullscreen = true + + val owner = DreamViewOwner().also { owner = it } + owner.create(window.decorView) + setContentView( + ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) + setContent { + ScreenSaverRoot(source, onNightModeChanged = { night -> isScreenBright = !night }) + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + owner?.start() + } + + override fun onDreamingStopped() { + owner?.stop() + super.onDreamingStopped() + } + + override fun onDetachedFromWindow() { + owner?.destroy() + owner = null + super.onDetachedFromWindow() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt new file mode 100644 index 0000000..0decb7e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner + +/** + * The lifecycle, view-model store and saved-state registry a `ComposeView` + * expects to find on its window, for a window no `ComponentActivity` owns — + * a `DreamService`'s. Without them the `ComposeView` throws on attach. + * + * The lifecycle follows the dream: created with its window, resumed while it + * is dreaming, stopped when it stops, destroyed when the window goes. A + * stopped lifecycle is what makes `collectAsStateWithLifecycle` let go of the + * minute ticks, so a dream that is no longer shown costs nothing. + */ +internal class DreamViewOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { + + private val registry = LifecycleRegistry(this) + private val savedState = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle get() = registry + override val viewModelStore: ViewModelStore = ViewModelStore() + override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry + + /** Restores (nothing — a dream keeps no state across windows) and moves to CREATED, then installs itself on [root]. */ + fun create(root: View) { + savedState.performRestore(null) + registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + root.setViewTreeLifecycleOwner(this) + root.setViewTreeViewModelStoreOwner(this) + root.setViewTreeSavedStateRegistryOwner(this) + } + + fun start() { + registry.currentState = Lifecycle.State.RESUMED + } + + fun stop() { + if (registry.currentState.isAtLeast(Lifecycle.State.STARTED)) registry.currentState = Lifecycle.State.CREATED + } + + fun destroy() { + if (registry.currentState == Lifecycle.State.INITIALIZED) return + registry.currentState = Lifecycle.State.DESTROYED + viewModelStore.clear() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt new file mode 100644 index 0000000..7f04e18 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt @@ -0,0 +1,91 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * The screen saver on demand, from the tabs' overflow menu. An app cannot + * start the system's dream without a system permission, so this shows the + * same [ScreenSaverRoot] full-screen instead, the way Google Clock's own + * "Screen saver" item does. + * + * Any touch closes it, as it would end a dream. It keeps the screen on only + * while the phone is charging: on battery the display times out as it + * normally would, so a screen saver left open in a bag does not drain it. + * Night mode turns the window's own brightness down to almost nothing. + */ +@AndroidEntryPoint +class ScreenSaverActivity : ComponentActivity() { + + @Inject internal lateinit var source: ScreenSaverSource + + private val powerReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowCompat.getInsetsController(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + setContent { ScreenSaverRoot(source, onNightModeChanged = ::applyNightBrightness) } + } + + override fun onStart() { + super.onStart() + // ACTION_BATTERY_CHANGED is sticky: registering returns the current + // state at once, then every change while the screen saver is shown. + val sticky = ContextCompat.registerReceiver( + this, + powerReceiver, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + sticky?.let(::applyKeepScreenOn) + } + + override fun onStop() { + unregisterReceiver(powerReceiver) + super.onStop() + } + + override fun onUserInteraction() { + super.onUserInteraction() + finish() + } + + private fun applyKeepScreenOn(battery: Intent) { + val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0 + if (charging) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun applyNightBrightness(nightMode: Boolean) { + window.attributes = window.attributes.apply { + screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + } + + private companion object { + /** Near the bottom of the panel's range; not 0, which some panels take as "off". */ + const val NIGHT_BRIGHTNESS = 0.01f + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt new file mode 100644 index 0000000..f8d23cf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt @@ -0,0 +1,232 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.text.format.DateFormat +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.os.ConfigurationCompat +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel +import de.jeanlucmakiola.clockula.domain.worldclock.DayNight +import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockFace +import de.jeanlucmakiola.floret.identity.rememberReduceMotion +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters +import java.util.Locale +import kotlin.math.roundToInt + +/** + * The screen saver itself, as both hosts draw it: the time — numerals or the + * world clock's dial — the date and the next alarm, on black, at a spot that + * moves once a minute ([ScreenSaverDrift]) so nothing stays lit in one place. + * The move is a slow crossfade rather than a slide, as DeskClock's is, and + * a cut under reduce motion. + */ +@Composable +internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) { + val reduceMotion = rememberReduceMotion() + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.displayCutout) + .padding(ScreenSaverDefaults.Margin), + ) { + // Sized from the screen's shorter side, so the clock is as large in + // landscape on a bedside dock as it is upright in the hand. + val side = minOf(maxWidth, maxHeight) + Crossfade( + targetState = state.position, + animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS), + label = "drift", + ) { position -> + Drifting(position) { Clock(state, side) } + } + } +} + +/** Places its one child at [position]'s fractions of the space the child leaves free. */ +@Composable +private fun Drifting(position: ScreenSaverDrift.Position, content: @Composable () -> Unit) { + Layout(content = content, modifier = Modifier.fillMaxSize()) { measurables, constraints -> + val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0)) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place( + x = ((constraints.maxWidth - placeable.width).coerceAtLeast(0) * position.x).roundToInt(), + y = ((constraints.maxHeight - placeable.height).coerceAtLeast(0) * position.y).roundToInt(), + ) + } + } +} + +@Composable +private fun Clock(state: ScreenSaverUiState, side: Dp) { + val locale = ConfigurationCompat.getLocales(LocalConfiguration.current)[0] ?: Locale.ROOT + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when (state.settings.style) { + ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side) + ScreenSaverStyle.ANALOG -> WorldClockFace( + time = state.time.toLocalTime(), + dayFraction = DayNight.fractionAt(state.time.toLocalTime()), + modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION), + showSecondHand = false, + ) + } + Spacer(Modifier.height(ScreenSaverDefaults.Gap)) + Text( + text = remember(state.time.toLocalDate(), locale) { dateLabel(state.time, locale) }, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (state.nextAlarm.hasAlarm) { + Spacer(Modifier.height(ScreenSaverDefaults.Gap / 2)) + NextAlarm(state.nextAlarm, state.time, state.use24Hour, locale) + } + } +} + +/** + * The time in numerals, scaled to the screen. In 12-hour time the AM/PM + * marker is kept — a bedside clock that cannot tell 7 in the morning from 7 + * at night is no use — but drawn small, where the locale's own pattern puts + * it. + */ +@Composable +private fun DigitalTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) { + val parts = remember(time, use24Hour, locale) { timeParts(time, use24Hour, locale) } + val digits = parts.before.length + parts.after.length + // About 0.6em per tabular digit or colon, so the readout fills ~90 % of + // the shorter side at any length. + val size = minOf(side * 0.9f / (digits.coerceAtLeast(4) * 0.6f), side * 0.4f) + val fontSize = with(LocalDensity.current) { size.toSp() } + val text = buildAnnotatedString { + append(parts.before) + if (parts.marker.isNotEmpty()) { + withStyle(SpanStyle(fontSize = fontSize * 0.3f)) { append(parts.marker) } + } + append(parts.after) + } + Text( + text = text, + style = MaterialTheme.typography.displayLarge.copy( + fontSize = fontSize, + lineHeight = fontSize, + // Thin strokes: fewer lit pixels all night, and the look of a + // bedside clock rather than a banner. + fontWeight = FontWeight.Light, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, + ) +} + +@Composable +private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) { + val label = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmLabel(model, now, use24Hour, locale) } + val description = stringResource(R.string.screen_saver_next_alarm, label) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description }, + ) { + Icon( + imageVector = Icons.Rounded.Alarm, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** A time split around its AM/PM marker; [marker] is empty in 24-hour time. */ +private data class TimeParts(val before: String, val marker: String, val after: String) + +private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts { + val pattern = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "Hmm" else "hmma") + val markerAt = pattern.indexOf('a') + if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "") + return runCatching { + TimeParts( + before = format(time, pattern.substring(0, markerAt), locale), + marker = format(time, "a", locale), + after = format(time, pattern.substring(markerAt + 1), locale), + ) + }.getOrElse { TimeParts(format(time, pattern, locale), "", "") } +} + +private fun format(time: LocalTime, pattern: String, locale: Locale): String = + if (pattern.isBlank()) pattern else DateTimeFormatter.ofPattern(pattern, locale).format(time) + +/** The locale's own full weekday-and-date form, "Thursday, October 22". */ +private fun dateLabel(now: LocalDateTime, locale: Locale): String = + DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "EEEEMMMMd"), locale).format(now) + +/** + * The next alarm as the platform's own screen saver writes it: the short + * weekday and the time, "Tue 7:00 AM", in the locale's order. The weekday + * is always there, so a time alone can never be read as today's. + */ +private fun nextAlarmLabel(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): String { + val day = model.dayOfWeek ?: now.dayOfWeek + val at = now.toLocalDate() + .with(TemporalAdjusters.nextOrSame(day)) + .atTime(model.hour, model.minute) + val pattern = DateFormat.getBestDateTimePattern(locale, if (use24Hour) "EEEHmm" else "EEEhmma") + return DateTimeFormatter.ofPattern(pattern, locale).format(at) +} + +private object ScreenSaverDefaults { + /** Keeps the clock off the very edge, where a curved panel bends it away. */ + val Margin: Dp = 24.dp + val Gap: Dp = 16.dp + + /** The analog face's share of the screen's shorter side. */ + const val FACE_FRACTION = 0.55f + + /** Slow enough to read as a drift, not a jump. */ + const val MOVE_MILLIS = 1_200 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt new file mode 100644 index 0000000..3822dc8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme +import de.jeanlucmakiola.floret.prefs.Appearance + +/** + * What both hosts set as their content: [source]'s state in the screen + * saver's theme. Collected with the host's lifecycle, so a stopped dream or + * a backgrounded activity stops waking for the minute as well. + * + * [onNightModeChanged] hands the night-mode choice back to the host, which + * owns the window and so the display's brightness. + */ +@Composable +internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) { + val appearance by source.appearance.collectAsStateWithLifecycle(initialValue = Appearance()) + val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null) + val nightMode = state?.settings?.nightMode ?: false + LaunchedEffect(nightMode) { onNightModeChanged(nightMode) } + + ScreenSaverTheme(nightMode = nightMode, dynamicColor = appearance.dynamicColor) { + // Black until the first state lands, rather than a frame of anything else. + state?.let { ScreenSaverContent(it) } + ?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt new file mode 100644 index 0000000..72be824 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.prefs.Appearance +import de.jeanlucmakiola.floret.prefs.isDark +import javax.inject.Inject + +/** + * The screen saver's settings as Android opens them: the gear next to + * Clockula in Settings → Display → Screen saver (`android:settingsActivity` + * in `res/xml/screen_saver_info.xml`). The same rows as Settings → Clock, + * in the app's own theme. + * + * Exported because only an exported activity can be started by the Settings + * app; it takes no extras and does nothing a user could not do in-app. + */ +@AndroidEntryPoint +class ScreenSaverSettingsActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance()) + ClockulaTheme( + darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()), + dynamicColor = appearance.dynamicColor, + ) { + CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) { + ScreenSaverSettingsSection(inApp = false) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt new file mode 100644 index 0000000..b86970b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position + +/** + * The screen saver's settings as a run of grouped rows: its style, night + * mode, and a way to see it now. [inApp] adds a last row into Android's own + * screen saver settings, where Clockula is chosen and its start condition + * set; the system's settings screen opens this one, so there it is left out. + */ +@Composable +internal fun ScreenSaverSettingsSection( + inApp: Boolean, + viewModel: ScreenSaverSettingsViewModel = hiltViewModel(), +) { + val settings by viewModel.state.collectAsStateWithLifecycle() + val current = settings ?: return + val context = LocalContext.current + var showStyle by rememberSaveable { mutableStateOf(false) } + + GroupedRow( + title = stringResource(R.string.screen_saver_style), + summary = styleLabel(current.style), + position = Position.Top, + onClick = { showStyle = true }, + ) + GroupedRow( + title = stringResource(R.string.screen_saver_night_mode), + summary = stringResource(R.string.screen_saver_night_mode_hint), + position = Position.Middle, + trailing = { Switch(checked = current.nightMode, onCheckedChange = viewModel::onNightModeChanged) }, + onClick = { viewModel.onNightModeChanged(!current.nightMode) }, + ) + GroupedRow( + title = stringResource(R.string.screen_saver_preview), + position = if (inApp) Position.Middle else Position.Bottom, + onClick = { openScreenSaver(context) }, + ) + if (inApp) { + GroupedRow( + title = stringResource(R.string.screen_saver_system_settings), + summary = stringResource(R.string.screen_saver_system_settings_hint), + position = Position.Bottom, + onClick = { openSystemScreenSaverSettings(context) }, + ) + } + + if (showStyle) { + OptionPicker( + title = stringResource(R.string.screen_saver_style), + options = ScreenSaverStyle.entries.toList(), + selected = current.style, + label = { styleLabel(it) }, + onSelect = viewModel::onStyleSelected, + onDismiss = { showStyle = false }, + ) + } +} + +@Composable +private fun styleLabel(style: ScreenSaverStyle): String = stringResource( + when (style) { + ScreenSaverStyle.DIGITAL -> R.string.screen_saver_style_digital + ScreenSaverStyle.ANALOG -> R.string.screen_saver_style_analog + }, +) + +/** The app's own full-screen screen saver ([ScreenSaverActivity]). */ +internal fun openScreenSaver(context: Context) { + context.startActivity(Intent(context, ScreenSaverActivity::class.java)) +} + +/** Android's screen saver settings. Some builds leave the screen out; then the row does nothing rather than crash. */ +private fun openSystemScreenSaverSettings(context: Context) { + runCatching { context.startActivity(Intent(Settings.ACTION_DREAM_SETTINGS)) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt new file mode 100644 index 0000000..ff9ba23 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The screen saver's two settings, for both places they are edited: Settings + * → Clock, and the system's own screen saver settings ([ScreenSaverSettingsActivity]). + * Every write lands at once, as everywhere else in Settings. Null until the + * first read, so a row never shows a default it is about to change. + */ +@HiltViewModel +class ScreenSaverSettingsViewModel @Inject constructor( + private val settings: SettingsPrefs, +) : ViewModel() { + + val state: StateFlow = settings.screenSaver + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), null) + + fun onStyleSelected(style: ScreenSaverStyle) = viewModelScope.launch { settings.setScreenSaverStyle(style) } + + fun onNightModeChanged(enabled: Boolean) = viewModelScope.launch { settings.setScreenSaverNightMode(enabled) } + + private companion object { + const val SUBSCRIPTION_GRACE_MS = 5_000L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt new file mode 100644 index 0000000..603be5a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt @@ -0,0 +1,95 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings +import de.jeanlucmakiola.clockula.domain.time.ClockChanges +import de.jeanlucmakiola.clockula.domain.time.HourFormat +import de.jeanlucmakiola.clockula.domain.time.MinuteBoundary +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter +import de.jeanlucmakiola.floret.prefs.Appearance +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.toJavaInstant + +/** One frame of the screen saver: everything it draws, resolved to the minute. */ +internal data class ScreenSaverUiState( + val settings: ScreenSaverSettings, + /** The device's local time, truncated to the minute. */ + val time: LocalDateTime, + val use24Hour: Boolean, + val nextAlarm: NextAlarmWidgetModel, + val position: ScreenSaverDrift.Position, +) + +/** + * Prefs + the alarm engine + the clocks → the screen saver's state, for both + * of its hosts: the system's `ClockulaDreamService` and the app's own + * `ScreenSaverActivity`. + * + * It wakes **once a minute**, on the minute ([MinuteBoundary]) — the face + * shows no seconds, so a sub-second ticker would only burn battery on a + * screen left up all night — and at once when the device's clock is changed + * ([ClockChanges]: the time, the zone, or 12/24-hour). The next alarm is the + * next-alarm widget's own model ([NextAlarmWidgetPresenter]), re-resolved on + * every wake so an alarm that just rang moves on to its next occurrence. + */ +@Singleton +internal class ScreenSaverSource @Inject constructor( + private val settings: SettingsPrefs, + private val alarmEngine: AlarmEngine, + private val wallClock: WallClock, + private val zones: ZoneProvider, + private val clockChanges: ClockChanges, + private val hourFormat: HourFormat, +) { + + val appearance: Flow = settings.appearance + + fun state(): Flow = + combine(settings.screenSaver, alarmEngine.upcoming(minuteTicks())) { screenSaver, upcoming -> + val now = wallClock.now() + val zone = zones.current() + ScreenSaverUiState( + settings = screenSaver, + time = ZonedDateTime.ofInstant(now.toJavaInstant(), zone) + .toLocalDateTime() + .truncatedTo(ChronoUnit.MINUTES), + use24Hour = hourFormat.is24Hour(), + nextAlarm = NextAlarmWidgetPresenter.present(upcoming, now, zone), + position = ScreenSaverDrift.positionAt(now), + ) + }.distinctUntilChanged() + + /** + * Emits at once, then at every minute boundary. A clock change restarts + * the wait, since a `delay` measured against the old time would land on + * the wrong boundary. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun minuteTicks(): Flow = clockChanges.changes() + .onStart { emit(Unit) } + .flatMapLatest { + flow { + while (true) { + emit(Unit) + delay(MinuteBoundary.untilNext(wallClock.now())) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt index d669685..20241ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt @@ -7,11 +7,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsSection import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.Position -/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */ +/** + * The clock tab's settings, and the screen saver's: it is a clock face, and + * the settings Android's own screen saver screen opens are the same rows. + * The zone picker itself is ViewModel state, drawn by the hub. + */ @Composable internal fun ClockSettingsScreen( state: SettingsUiState.Loaded, @@ -28,5 +34,9 @@ internal fun ClockSettingsScreen( position = Position.Alone, onClick = viewModel::onOpenHomeZonePicker, ) + + Spacer(Modifier.height(16.dp)) + GroupedSectionHeader(stringResource(R.string.screen_saver_title)) + ScreenSaverSettingsSection(inApp = true) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt new file mode 100644 index 0000000..09e1f00 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Nightlight +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.screensaver.openScreenSaver + +/** + * Every tab's top-bar overflow, as Google Clock's: the screen saver, then + * Settings. The screen saver opens directly — it is an activity of its own, + * not a destination in the shell's graph. + */ +@Composable +internal fun ShellOverflowMenu(onOpenSettings: () -> Unit) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.menu_more_options), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.screen_saver_title)) }, + leadingIcon = { Icon(Icons.Rounded.Nightlight, contentDescription = null) }, + onClick = { + expanded = false + openScreenSaver(context) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_title)) }, + leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) }, + onClick = { + expanded = false + onOpenSettings() + }, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt index be83ee5..9538565 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt @@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.StopwatchState import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -63,12 +60,7 @@ fun StopwatchScreen( modifier = modifier, scrollable = false, actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, ) { Column( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt new file mode 100644 index 0000000..579567e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.clockula.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +/** + * The screen saver's theme: always Clockula's dark scheme — wallpaper colours + * when [dynamicColor] — over true black, because the screen saver runs in a + * dark room more often than not and black pixels on an OLED panel are off. + * + * [nightMode] dims every role the face draws with towards black, so the + * faces keep their usual tokens (the analog face is the world clock's own + * `WorldClockFace`) and simply come out very dim. The window's brightness is + * turned down by the host as well; the two together are what makes the + * night mode readable without lighting the room. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenSaverTheme( + nightMode: Boolean, + dynamicColor: Boolean, + content: @Composable () -> Unit, +) { + ClockulaTheme(darkTheme = true, dynamicColor = dynamicColor) { + val base = MaterialTheme.colorScheme + val scheme = remember(base, nightMode) { base.forScreenSaver(nightMode) } + MaterialExpressiveTheme( + colorScheme = scheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + motionScheme = MaterialTheme.motionScheme, + content = content, + ) + } +} + +/** How much of each role survives night mode: enough to read across a dark bedroom, little enough not to light it. */ +private const val NIGHT_LEVEL = 0.32f + +private fun ColorScheme.forScreenSaver(nightMode: Boolean): ColorScheme { + val black = copy(background = Color.Black, surface = Color.Black) + if (!nightMode) return black + fun Color.dim(): Color = lerp(Color.Black, this, NIGHT_LEVEL) + return black.copy( + primary = primary.dim(), + onSurface = onSurface.dim(), + onSurfaceVariant = onSurfaceVariant.dim(), + // The analog face's dial and marks. + secondaryContainer = secondaryContainer.dim(), + onSecondaryContainer = onSecondaryContainer.dim(), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 0741321..4681572 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -59,12 +58,7 @@ fun TimersScreen( modifier = modifier, scrollable = false, actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, floatingActionButton = { // Only over a list: on an empty tab the keypad is already on diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt index 2c9ea05..36cf557 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt @@ -42,6 +42,10 @@ import java.time.LocalTime * Scheme tokens only, no literal colour. The face is hidden from screen * readers: the time readout under it says the same time as text, so * describing the face as well would read the time twice. + * + * The screen saver reuses it at its own size (a [modifier] size wins over + * the hero size) and without the second hand: it redraws once a minute, and + * a second hand that only moved once a minute would be a broken one. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -49,6 +53,7 @@ internal fun WorldClockFace( time: LocalTime, dayFraction: Float, modifier: Modifier = Modifier, + showSecondHand: Boolean = true, ) { val dialColor = MaterialTheme.colorScheme.secondaryContainer val markColor = MaterialTheme.colorScheme.onSecondaryContainer @@ -82,7 +87,7 @@ internal fun WorldClockFace( ) { drawDial(morph, progress, dialPath, dialMatrix, dialColor) drawTicks(markColor) - drawHands(time, markColor, accentColor) + drawHands(time, markColor, accentColor, showSecondHand) } } @@ -127,11 +132,11 @@ private fun DrawScope.drawTicks(color: Color) { } } -private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color) { +private fun DrawScope.drawHands(time: LocalTime, handColor: Color, accentColor: Color, showSecondHand: Boolean) { val radius = size.minDimension / 2f hand(AnalogFace.hourAngle(time), radius * 0.46f, 5.dp.toPx(), handColor) hand(AnalogFace.minuteAngle(time), radius * 0.68f, 4.dp.toPx(), handColor) - hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) + if (showSecondHand) hand(AnalogFace.secondAngle(time), radius * 0.74f, 2.dp.toPx(), accentColor) drawCircle(accentColor, radius = 4.dp.toPx(), center = center) drawCircle(handColor, radius = 2.dp.toPx(), center = center, style = Stroke(width = 1.dp.toPx())) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index abac9b9..2fdc54c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Home -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -35,6 +34,7 @@ import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedSurface @@ -87,12 +87,7 @@ fun WorldClockScreen( contentDescription = stringResource(R.string.world_clock_home_action), ) } - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, floatingActionButton = { val canAdd = loaded?.canAdd != false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ad0a66c..ca234b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -301,9 +301,21 @@ Sound for new timers New timers ring with this sound. Each timer can still change it in its editor. Clock - Home time zone + Home time zone and screen saver World clocks show how far each city is from your home time zone. Home time zone + Screen saver + Clock style + Digital + Analog + Night mode + Very dim display, for dark rooms + Open screen saver + Choose when it starts + Pick Clockula as the screen saver in Android\'s settings + + Next alarm: %1$s + More options Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9230f70..686e75d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,6 +9,14 @@ title-less so nothing flashes on the way through (M9 D3). --> +