Merge branch 'feat/25-screen-saver' into release/v1.0.0
# Conflicts: # app/src/main/AndroidManifest.xml # app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt # app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt # app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt # app/src/main/java/de/jeanlucmakiola/clockula/domain/widget/NextAlarmWidgetModel.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt # app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt # app/src/main/res/values/strings.xml # app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt # app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt # docs/ARCHITECTURE.md # docs/BACKUP.md
This commit is contained in:
@@ -103,3 +103,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
analog and digital clocks that show your world clocks at larger sizes. Each
|
||||
widget can follow the system theme or stay light or dark, and choose what a
|
||||
tap opens.
|
||||
- **Screen saver.** A full-screen digital or analog clock with the date and
|
||||
your next alarm, for a phone on a charger or a dock. Night mode turns it
|
||||
very dim for a dark bedroom, and the clock moves a little every minute so
|
||||
nothing burns into the screen. Pick it in Android's screen saver settings,
|
||||
or open it any time from the menu on each tab. Its settings are in Settings
|
||||
→ Clock and are included in backups.
|
||||
|
||||
@@ -285,6 +285,51 @@
|
||||
android:name=".bedtime.receiver.BedtimeReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The screen saver (issue #25). The system binds it on a charger or a
|
||||
dock once the user picks it in Settings → Display → Screen saver.
|
||||
Exported, since the dream manager is another process, and guarded
|
||||
by BIND_DREAM_SERVICE, a signature permission only the system
|
||||
holds, so nothing else can bind it. -->
|
||||
<service
|
||||
android:name=".ui.screensaver.ClockulaDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.dream"
|
||||
android:resource="@xml/screen_saver_info" />
|
||||
</service>
|
||||
|
||||
<!-- The screen saver's settings, opened by the gear beside it in the
|
||||
system's screen saver list. Exported because only an exported
|
||||
activity can be started by the Settings app; it reads no extras
|
||||
and changes nothing a user could not change in-app. A task of its
|
||||
own and out of recents, so opening it from Settings never lands
|
||||
in, or clears, the app's own task. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverSettingsActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="@string/screen_saver_title"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Clockula" />
|
||||
|
||||
<!-- The same screen saver on demand, from the tabs' overflow menu: an
|
||||
app cannot start the system's dream itself. Not exported; it
|
||||
finishes itself once no longer shown. Rotating it into a stand,
|
||||
docking it, or the dark-theme schedule firing re-lays it out
|
||||
rather than recreating it, which would flash black and reset its
|
||||
brightness. -->
|
||||
<activity
|
||||
android:name=".ui.screensaver.ScreenSaverActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode|density|fontScale|locale|layoutDirection"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Clockula.ScreenSaver" />
|
||||
|
||||
<!-- Persists the per-app language on API < 33, where the platform
|
||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||
<service
|
||||
|
||||
@@ -116,6 +116,7 @@ class BackupEngine @Inject constructor(
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
worldClockSeconds = settings.worldClockSeconds.first(),
|
||||
screenSaver = settings.screenSaver.first(),
|
||||
bedtime = settings.currentBedtime(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeLimits
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
@@ -147,6 +149,8 @@ object BackupCodec {
|
||||
worldClockSeconds = worldClockSeconds,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
screenSaverStyle = screenSaver.style.name,
|
||||
screenSaverNightMode = screenSaver.nightMode,
|
||||
)
|
||||
|
||||
private fun BedtimeSettings.toDto() = BedtimeDto(
|
||||
@@ -305,6 +309,10 @@ object BackupCodec {
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
worldClockSeconds = settingsDto.worldClockSeconds,
|
||||
screenSaver = ScreenSaverSettings(
|
||||
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
|
||||
nightMode = settingsDto.screenSaverNightMode,
|
||||
),
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
)
|
||||
|
||||
|
||||
@@ -105,6 +105,9 @@ data class SettingsDto(
|
||||
@SerialName("timer_presets") val timerPresets: List<Long>? = null,
|
||||
/** Absent from files written before issue #1; restoring one keeps the current bedtime settings. */
|
||||
val bedtime: BedtimeDto? = null,
|
||||
/** Absent in files from before the screen saver; they restore its defaults. */
|
||||
@SerialName("screen_saver_style") val screenSaverStyle: String = "DIGITAL",
|
||||
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidClockChanges
|
||||
import de.jeanlucmakiola.clockula.data.time.AndroidHourFormat
|
||||
import de.jeanlucmakiola.clockula.data.time.RealTicker
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
@@ -44,4 +46,7 @@ abstract class TimeModule {
|
||||
|
||||
@Binds
|
||||
abstract fun bindClockChanges(impl: AndroidClockChanges): ClockChanges
|
||||
|
||||
@Binds
|
||||
abstract fun bindHourFormat(impl: AndroidHourFormat): HourFormat
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.jeanlucmakiola.clockula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
|
||||
/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */
|
||||
object ScreenSaverPrefs {
|
||||
|
||||
val style: Pref<ScreenSaverStyle> = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL)
|
||||
|
||||
val nightMode: Pref<Boolean> = booleanPref("screen_saver_night_mode", false)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||
@@ -129,6 +131,8 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
prefs[ScreenSaverPrefs.style] = backup.screenSaver.style
|
||||
prefs[ScreenSaverPrefs.nightMode] = backup.screenSaver.nightMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +275,19 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
||||
this[BedtimePrefs.hideAlwaysOnDisplay] = settings.effects.hideAlwaysOnDisplay
|
||||
}
|
||||
|
||||
// --- screen saver (issue #25) ---
|
||||
|
||||
/** Built once and composed from its two keys, for the same reasons [defaults] is. */
|
||||
val screenSaver: Flow<ScreenSaverSettings> = combine(
|
||||
store.flow(ScreenSaverPrefs.style),
|
||||
store.flow(ScreenSaverPrefs.nightMode),
|
||||
::ScreenSaverSettings,
|
||||
).distinctUntilChanged()
|
||||
|
||||
suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style)
|
||||
|
||||
suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled)
|
||||
|
||||
// --- first-launch bookkeeping (M4) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.text.format.DateFormat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
@@ -40,3 +42,10 @@ class AndroidClockChanges @Inject constructor(
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
|
||||
/** `DateFormat.is24HourFormat`: the user's choice, else the locale's. */
|
||||
class AndroidHourFormat @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : HourFormat {
|
||||
override fun is24Hour(): Boolean = DateFormat.is24HourFormat(context)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import java.time.LocalDate
|
||||
import kotlin.time.Duration
|
||||
@@ -63,6 +64,7 @@ data class BackupSettings(
|
||||
val timerPresets: List<Duration>,
|
||||
/** The world clock's "Display time with seconds" (#23). */
|
||||
val worldClockSeconds: Boolean,
|
||||
val screenSaver: ScreenSaverSettings,
|
||||
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
|
||||
val bedtime: BedtimeSettings? = null,
|
||||
)
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* How the screen saver names the day of the next alarm. A weekday alone
|
||||
* ("Thu") is only unambiguous for the coming six days: a weekly alarm whose
|
||||
* time has passed today next rings in seven, and a skipped one in fourteen,
|
||||
* and either would read as today's. Those carry their date as well.
|
||||
*/
|
||||
object ScreenSaverAlarmDay {
|
||||
|
||||
private const val DAYS_A_WEEKDAY_NAMES = 7L
|
||||
|
||||
fun weekdayIsEnough(fireDate: LocalDate, today: LocalDate): Boolean =
|
||||
fireDate.isBefore(today.plusDays(DAYS_A_WEEKDAY_NAMES))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Where the screen saver's clock sits, so a face left up all night never
|
||||
* burns into an OLED panel: it moves to a new spot once a minute, as AOSP's
|
||||
* DeskClock screen saver does.
|
||||
*
|
||||
* A position is a pair of fractions of the *free* space — the screen minus
|
||||
* the clock itself — so `0f` is flush with the left/top edge and `1f` with
|
||||
* the right/bottom one, at any screen and clock size. Derived from the minute
|
||||
* alone rather than from a random source, so the clock lands on the same spot
|
||||
* however often the minute is re-read (a recomposition, a time change) and
|
||||
* moves only when the minute does.
|
||||
*/
|
||||
object ScreenSaverDrift {
|
||||
|
||||
data class Position(val x: Float, val y: Float)
|
||||
|
||||
/** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */
|
||||
fun positionAt(now: Instant): Position {
|
||||
val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE)
|
||||
val bits = mix(minute)
|
||||
return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16))
|
||||
}
|
||||
|
||||
/** The low 24 bits of [bits] as a fraction in `[0, 1]`. */
|
||||
private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24
|
||||
|
||||
/**
|
||||
* SplitMix64's finaliser: consecutive minutes land on unrelated spots,
|
||||
* where a plain modulo would walk the clock across the screen in a line.
|
||||
*/
|
||||
private fun mix(seed: Long): Long {
|
||||
var z = seed + GOLDEN_GAMMA
|
||||
z = (z xor (z ushr 30)) * MIX_1
|
||||
z = (z xor (z ushr 27)) * MIX_2
|
||||
return z xor (z ushr 31)
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MASK_24 = 0xFFFFFFL
|
||||
private const val GOLDEN_GAMMA = -0x61c8864680b583ebL
|
||||
private const val MIX_1 = -0x40a7b892e31b1a47L
|
||||
private const val MIX_2 = -0x6b2fb644ecceee15L
|
||||
}
|
||||
+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,
|
||||
)
|
||||
@@ -51,11 +51,16 @@ interface Ticker {
|
||||
}
|
||||
|
||||
/**
|
||||
* The system saying the wall clock may not read what a readout last drew: the
|
||||
* time was set or the zone changed. A readout on a long cadence restarts its
|
||||
* tick on this, so it does not wait out its own period to catch up. Emits
|
||||
* only while collected.
|
||||
* The device's clock being changed under a running readout: the time set by
|
||||
* hand or by the network, the zone, or the 12/24-hour choice, which the
|
||||
* platform announces as a time change too. Emits once per change while
|
||||
* collected. Injected, so a test hands the change over instead of making one.
|
||||
*/
|
||||
interface ClockChanges {
|
||||
fun changes(): Flow<Unit>
|
||||
}
|
||||
|
||||
/** The device's 12/24-hour choice, read afresh on every call. */
|
||||
interface HourFormat {
|
||||
fun is24Hour(): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.jeanlucmakiola.clockula.domain.time
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How long a minute-resolution readout may sleep: until the next wall-clock
|
||||
* minute begins, so it changes on the minute rather than up to a minute late,
|
||||
* and wakes once a minute rather than on a fixed sub-second cadence.
|
||||
*/
|
||||
object MinuteBoundary {
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
|
||||
/**
|
||||
* From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a
|
||||
* boundary is a whole minute away, never zero, so a caller looping on it
|
||||
* cannot spin.
|
||||
*/
|
||||
fun untilNext(now: Instant): Duration {
|
||||
val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE)
|
||||
return (MILLIS_PER_MINUTE - intoMinute).milliseconds
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -35,6 +33,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
@@ -78,12 +77,7 @@ fun AlarmsScreen(
|
||||
title = stringResource(R.string.tab_alarms),
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -6,9 +6,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import java.time.LocalTime
|
||||
import java.time.chrono.IsoChronology
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Honours the user's 12/24-hour setting. Deliberately not unit-tested: it is
|
||||
@@ -27,20 +31,29 @@ internal fun rememberLocalTimeFormatter(withSeconds: Boolean = false): (LocalTim
|
||||
// The locale can change under a running activity, and the 12/24-hour
|
||||
// choice rides the same configuration, so both are re-read with it.
|
||||
val configuration = LocalConfiguration.current
|
||||
val formatter = remember(configuration, withSeconds) {
|
||||
// The device's 12/24-hour choice, then the locale's own short form —
|
||||
// the same two-step the ring notification already formats with.
|
||||
if (DateFormat.is24HourFormat(context)) {
|
||||
DateTimeFormatter.ofPattern(if (withSeconds) "HH:mm:ss" else "HH:mm")
|
||||
} else {
|
||||
DateTimeFormatter.ofLocalizedTime(if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT)
|
||||
}
|
||||
val locale = currentLocale()
|
||||
val formatter = remember(configuration, locale, withSeconds) {
|
||||
DateTimeFormatter.ofPattern(localTimePattern(DateFormat.is24HourFormat(context), locale, withSeconds), locale)
|
||||
}
|
||||
return remember(formatter) {
|
||||
{ time -> formatter.format(time) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule itself, as a pattern: the device's 12/24-hour choice, then the
|
||||
* locale's own short form — the same two-step the ring notification already
|
||||
* formats with. A pattern rather than a formatter for the screen saver, which
|
||||
* splits it around the AM/PM marker to draw that smaller.
|
||||
*/
|
||||
internal fun localTimePattern(is24Hour: Boolean, locale: Locale, withSeconds: Boolean = false): String =
|
||||
if (is24Hour) {
|
||||
if (withSeconds) "HH:mm:ss" else "HH:mm"
|
||||
} else {
|
||||
val style = if (withSeconds) FormatStyle.MEDIUM else FormatStyle.SHORT
|
||||
DateTimeFormatterBuilder.getLocalizedDateTimePattern(null, style, IsoChronology.INSTANCE, locale)
|
||||
}
|
||||
|
||||
/** Delegates to [rememberLocalTimeFormatter]. Unchanged for every existing caller. */
|
||||
@Composable
|
||||
internal fun rememberAlarmTimeFormatter(): (TimeOfDay) -> String {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.service.dreams.DreamService
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Clockula's screen saver, as the system starts it — on a charger or a dock,
|
||||
* from Settings → Display → Screen saver. Non-interactive: any touch wakes
|
||||
* the device, as a screen saver should. Night mode turns the display down to
|
||||
* the system's dim level ([setScreenBright]) and the window's own
|
||||
* brightness to the same near-zero the in-app host uses, on top of the
|
||||
* dimmed colours.
|
||||
*
|
||||
* Lives in `ui/` because it hosts Compose. Its `ComposeView` finds its
|
||||
* lifecycle, view-model store and saved-state owners on the window's decor
|
||||
* view, where [DreamViewOwner] puts them before the view is attached.
|
||||
*
|
||||
* Its settings screen is [ScreenSaverSettingsActivity], named in
|
||||
* `res/xml/screen_saver_info.xml`.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ClockulaDreamService : DreamService() {
|
||||
|
||||
@Inject internal lateinit var source: ScreenSaverSource
|
||||
|
||||
private var owner: DreamViewOwner? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
isInteractive = false
|
||||
isFullscreen = true
|
||||
// Dim until the settings say otherwise (ScreenSaverRoot).
|
||||
isScreenBright = false
|
||||
window.applyScreenSaverBrightness(nightMode = true)
|
||||
|
||||
val owner = DreamViewOwner().also { owner = it }
|
||||
owner.create(window.decorView)
|
||||
setContentView(
|
||||
// The window's context, not the service's: a Service is not a UI
|
||||
// context, and the window's knows its display's density and size.
|
||||
ComposeView(window.context).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
|
||||
setContent {
|
||||
ScreenSaverRoot(source, onNightModeChanged = { night ->
|
||||
isScreenBright = !night
|
||||
window.applyScreenSaverBrightness(night)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDreamingStarted() {
|
||||
super.onDreamingStarted()
|
||||
owner?.start()
|
||||
}
|
||||
|
||||
override fun onDreamingStopped() {
|
||||
owner?.stop()
|
||||
super.onDreamingStopped()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
owner?.destroy()
|
||||
owner = null
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
|
||||
/**
|
||||
* The lifecycle, view-model store and saved-state registry a `ComposeView`
|
||||
* expects to find on its window, for a window no `ComponentActivity` owns —
|
||||
* a `DreamService`'s. Without them the `ComposeView` throws on attach.
|
||||
*
|
||||
* The lifecycle follows the dream: created with its window, resumed while it
|
||||
* is dreaming, stopped when it stops, destroyed when the window goes. A
|
||||
* stopped lifecycle is what makes `collectAsStateWithLifecycle` let go of the
|
||||
* minute ticks, so a dream that is no longer shown costs nothing.
|
||||
*/
|
||||
internal class DreamViewOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val registry = LifecycleRegistry(this)
|
||||
private val savedState = SavedStateRegistryController.create(this)
|
||||
|
||||
override val lifecycle: Lifecycle get() = registry
|
||||
override val viewModelStore: ViewModelStore = ViewModelStore()
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry
|
||||
|
||||
/** Restores (nothing — a dream keeps no state across windows) and moves to CREATED, then installs itself on [root]. */
|
||||
fun create(root: View) {
|
||||
savedState.performRestore(null)
|
||||
registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
root.setViewTreeLifecycleOwner(this)
|
||||
root.setViewTreeViewModelStoreOwner(this)
|
||||
root.setViewTreeSavedStateRegistryOwner(this)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
registry.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (registry.currentState.isAtLeast(Lifecycle.State.STARTED)) registry.currentState = Lifecycle.State.CREATED
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (registry.currentState == Lifecycle.State.INITIALIZED) return
|
||||
registry.currentState = Lifecycle.State.DESTROYED
|
||||
viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The screen saver on demand, from the tabs' overflow menu. An app cannot
|
||||
* start the system's dream without a system permission, so this shows the
|
||||
* same [ScreenSaverRoot] full-screen instead, the way Google Clock's own
|
||||
* "Screen saver" item does.
|
||||
*
|
||||
* Any touch closes it, as it would end a dream. It keeps the screen on only
|
||||
* while the phone is charging: on battery the display times out as it
|
||||
* normally would, so a screen saver left open in a bag does not drain it.
|
||||
* Kept on or not, the phone still locks on its usual timeout
|
||||
* (`FLAG_ALLOW_LOCK_WHILE_SCREEN_ON`, as DeskClock's screen saver does) and
|
||||
* the screen saver carries on over the lock screen, so a phone left on the
|
||||
* charger overnight is not left unlocked; a touch then lands on the keyguard.
|
||||
* Night mode turns the window's own brightness down to almost nothing; the
|
||||
* window starts that way, until the settings are known.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ScreenSaverActivity : ComponentActivity() {
|
||||
|
||||
@Inject internal lateinit var source: ScreenSaverSource
|
||||
|
||||
private val powerReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) = applyKeepScreenOn(intent)
|
||||
}
|
||||
|
||||
/** The flag last applied; null until the first battery state. Changing a window flag costs a relayout, and the battery reports far more often than the plug changes. */
|
||||
private var keepingScreenOn: Boolean? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setShowWhenLocked(true)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON)
|
||||
window.applyScreenSaverBrightness(nightMode = true)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
setContent { ScreenSaverRoot(source, onNightModeChanged = window::applyScreenSaverBrightness) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// ACTION_BATTERY_CHANGED is sticky: registering returns the current
|
||||
// state at once, then every change while the screen saver is shown.
|
||||
val sticky = ContextCompat.registerReceiver(
|
||||
this,
|
||||
powerReceiver,
|
||||
IntentFilter(Intent.ACTION_BATTERY_CHANGED),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
sticky?.let(::applyKeepScreenOn)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
unregisterReceiver(powerReceiver)
|
||||
super.onStop()
|
||||
// Gone once it is no longer shown — Home, Recents, or the screen
|
||||
// timing out on battery — as a dream ends, so the next wake shows the
|
||||
// lock screen and the next launch the app, never a screen saver left
|
||||
// behind. A rotation recreates nothing (configChanges in the manifest).
|
||||
if (!isChangingConfigurations) finish()
|
||||
}
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun applyKeepScreenOn(battery: Intent) {
|
||||
val charging = battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
|
||||
if (charging == keepingScreenOn) return
|
||||
keepingScreenOn = charging
|
||||
if (charging) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Alarm
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverAlarmDay
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.DayNight
|
||||
import de.jeanlucmakiola.clockula.ui.common.localTimePattern
|
||||
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockFace
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The screen saver itself, as both hosts draw it: the time — numerals or the
|
||||
* world clock's dial — the date and the next alarm, on black, at a spot that
|
||||
* moves once a minute ([ScreenSaverDrift]) so nothing stays lit in one place.
|
||||
* The move is a slow crossfade rather than a slide, as DeskClock's is, and
|
||||
* a cut under reduce motion: the old minute fades out where it was as the
|
||||
* new one fades in at its new spot. Only a move fades: a new next alarm or
|
||||
* a settings change redraws in place.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.displayCutout)
|
||||
.padding(ScreenSaverDefaults.Margin),
|
||||
) {
|
||||
// Sized from the screen's shorter side, so the clock is as large in
|
||||
// landscape on a bedside dock as it is upright in the hand.
|
||||
val side = minOf(maxWidth, maxHeight)
|
||||
// The last frame drawn at each spot, so the outgoing copy keeps its
|
||||
// own minute while it fades. Two are all a crossfade ever shows.
|
||||
val frames = remember { RecentFrames() }
|
||||
frames.record(state)
|
||||
Crossfade(
|
||||
targetState = state.position,
|
||||
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
|
||||
label = "drift",
|
||||
) { position ->
|
||||
val current = position == state.position
|
||||
val shown = if (current) state else frames[position] ?: state
|
||||
// The outgoing minute is only a picture while it fades: a screen
|
||||
// reader finds one time on screen, never two.
|
||||
val semantics = if (current) Modifier else Modifier.clearAndSetSemantics {}
|
||||
Drifting(position, semantics) { Clock(shown, side) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest two frames by position; older ones have finished fading. */
|
||||
private class RecentFrames : LinkedHashMap<ScreenSaverDrift.Position, ScreenSaverUiState>() {
|
||||
/**
|
||||
* Re-inserted, not just replaced, so a spot the clock returns to (the
|
||||
* time set back a minute) counts as newest and the frame fading out is
|
||||
* never the one evicted.
|
||||
*/
|
||||
fun record(state: ScreenSaverUiState) {
|
||||
remove(state.position)
|
||||
put(state.position, state)
|
||||
}
|
||||
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ScreenSaverDrift.Position, ScreenSaverUiState>?): Boolean =
|
||||
size > 2
|
||||
}
|
||||
|
||||
/** Places its one child at [position]'s fractions of the space the child leaves free. */
|
||||
@Composable
|
||||
private fun Drifting(position: ScreenSaverDrift.Position, modifier: Modifier, content: @Composable () -> Unit) {
|
||||
Layout(content = content, modifier = modifier.fillMaxSize()) { measurables, constraints ->
|
||||
val placeable = measurables.single().measure(constraints.copy(minWidth = 0, minHeight = 0))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
placeable.place(
|
||||
x = ((constraints.maxWidth - placeable.width).coerceAtLeast(0) * position.x).roundToInt(),
|
||||
y = ((constraints.maxHeight - placeable.height).coerceAtLeast(0) * position.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Clock(state: ScreenSaverUiState, side: Dp) {
|
||||
val locale = currentLocale()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
when (state.settings.style) {
|
||||
ScreenSaverStyle.DIGITAL -> DigitalTime(state.time.toLocalTime(), state.use24Hour, locale, side)
|
||||
ScreenSaverStyle.ANALOG -> AnalogTime(state.time.toLocalTime(), state.use24Hour, locale, side)
|
||||
}
|
||||
Spacer(Modifier.height(ScreenSaverDefaults.Gap))
|
||||
Text(
|
||||
text = remember(state.time.toLocalDate(), locale) { dateLabel(state.time, locale) },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.nextAlarm.hasAlarm) {
|
||||
Spacer(Modifier.height(ScreenSaverDefaults.Gap / 2))
|
||||
NextAlarm(state.nextAlarm, state.time, state.use24Hour, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The time in numerals, scaled to the screen. In 12-hour time the AM/PM
|
||||
* marker is kept — a bedside clock that cannot tell 7 in the morning from 7
|
||||
* at night is no use — but drawn small, where the locale's own pattern puts
|
||||
* it.
|
||||
*/
|
||||
@Composable
|
||||
private fun DigitalTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
|
||||
val parts = remember(time, use24Hour, locale) { timeParts(time, use24Hour, locale) }
|
||||
// About 0.6em per tabular digit or colon, the marker at its own smaller
|
||||
// size, so the readout spans DIGITS_FRACTION of the shorter side at any
|
||||
// length — never all of it, or upright on a phone there would be no room
|
||||
// left across the screen to drift into.
|
||||
val ems = (parts.before.length + parts.after.length + parts.marker.length * MARKER_SCALE)
|
||||
.coerceAtLeast(4f) * 0.6f
|
||||
val size = minOf(side * DIGITS_FRACTION / ems, side * 0.32f)
|
||||
val fontSize = with(LocalDensity.current) { size.toSp() }
|
||||
val text = buildAnnotatedString {
|
||||
append(parts.before)
|
||||
if (parts.marker.isNotEmpty()) {
|
||||
withStyle(SpanStyle(fontSize = fontSize * MARKER_SCALE)) { append(parts.marker) }
|
||||
}
|
||||
append(parts.after)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.displayLarge.copy(
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize,
|
||||
// Thin strokes: fewer lit pixels all night, and the look of a
|
||||
// bedside clock rather than a banner.
|
||||
fontWeight = FontWeight.Light,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The world clock's own face, without its second hand (the screen saver
|
||||
* redraws once a minute). The face hides itself from screen readers because
|
||||
* on the World clock tab a text readout sits under it; here nothing does, so
|
||||
* the face is given the time as its description.
|
||||
*/
|
||||
@Composable
|
||||
private fun AnalogTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side: Dp) {
|
||||
val description = remember(time, use24Hour, locale) { timeFormatter(use24Hour, locale).format(time) }
|
||||
Box(Modifier.semantics { contentDescription = description }) {
|
||||
WorldClockFace(
|
||||
time = time,
|
||||
dayFraction = DayNight.fractionAt(time),
|
||||
modifier = Modifier.size(side * ScreenSaverDefaults.FACE_FRACTION),
|
||||
showSecondHand = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
|
||||
val (day, time) = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmParts(model, now, use24Hour, locale) }
|
||||
val label = stringResource(R.string.screen_saver_next_alarm_when, day, time)
|
||||
val description = stringResource(R.string.screen_saver_next_alarm, label)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = description },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Alarm,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The AM/PM marker's size against the digits'. */
|
||||
private const val MARKER_SCALE = 0.3f
|
||||
|
||||
/** The digital readout's share of the screen's shorter side. */
|
||||
private const val DIGITS_FRACTION = 0.7f
|
||||
|
||||
/** The app's one 12/24-hour rule ([localTimePattern]), so the screen saver never writes a time the alarm list writes differently. */
|
||||
private fun timeFormatter(use24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(localTimePattern(use24Hour, locale), locale)
|
||||
|
||||
/** A time split around its AM/PM marker; [marker] is empty in 24-hour time. */
|
||||
private data class TimeParts(val before: String, val marker: String, val after: String)
|
||||
|
||||
private fun timeParts(time: LocalTime, use24Hour: Boolean, locale: Locale): TimeParts {
|
||||
val pattern = localTimePattern(use24Hour, locale)
|
||||
val markerAt = markerIndex(pattern)
|
||||
if (markerAt < 0) return TimeParts(format(time, pattern, locale), "", "")
|
||||
return runCatching {
|
||||
TimeParts(
|
||||
before = format(time, pattern.substring(0, markerAt), locale),
|
||||
marker = format(time, "a", locale),
|
||||
after = format(time, pattern.substring(markerAt + 1), locale),
|
||||
)
|
||||
}.getOrElse { TimeParts(format(time, pattern, locale), "", "") }
|
||||
}
|
||||
|
||||
/** Where the pattern's AM/PM field is, skipping quoted literal text; -1 when it has none. */
|
||||
private fun markerIndex(pattern: String): Int {
|
||||
var quoted = false
|
||||
pattern.forEachIndexed { index, char ->
|
||||
when {
|
||||
char == '\'' -> quoted = !quoted
|
||||
!quoted && char == 'a' -> return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun format(time: LocalTime, pattern: String, locale: Locale): String =
|
||||
if (pattern.isBlank()) pattern else DateTimeFormatter.ofPattern(pattern, locale).format(time)
|
||||
|
||||
/** The locale's own full weekday-and-date form, "Thursday, October 22". */
|
||||
private fun dateLabel(now: LocalDateTime, locale: Locale): String =
|
||||
localizedDateFormatter(locale, "EEEEMMMMd").format(now)
|
||||
|
||||
/**
|
||||
* The next alarm as the platform's own screen saver writes it: the short
|
||||
* weekday and the time, "Tue 7:00 AM". The weekday is always there, so a
|
||||
* time alone can never be read as today's, and an alarm a week or more away
|
||||
* carries its date too ([ScreenSaverAlarmDay]), "Thu, Oct 29 7:00 AM". The
|
||||
* time follows the app's one 12/24-hour rule, like the big readout above it;
|
||||
* the two are joined by a translatable string, so a language can reorder them.
|
||||
*/
|
||||
private fun nextAlarmParts(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): Pair<String, String> {
|
||||
// No date means the fire is within the coming week, on its weekday.
|
||||
val today = now.toLocalDate()
|
||||
val date = model.date ?: model.dayOfWeek?.let { today.with(TemporalAdjusters.nextOrSame(it)) } ?: today
|
||||
val skeleton = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
|
||||
return localizedDateFormatter(locale, skeleton).format(date) to
|
||||
timeFormatter(use24Hour, locale).format(LocalTime.of(model.hour, model.minute))
|
||||
}
|
||||
|
||||
private object ScreenSaverDefaults {
|
||||
/** Keeps the clock off the very edge, where a curved panel bends it away. */
|
||||
val Margin: Dp = 24.dp
|
||||
val Gap: Dp = 16.dp
|
||||
|
||||
/** The analog face's share of the screen's shorter side. */
|
||||
const val FACE_FRACTION = 0.55f
|
||||
|
||||
/** Slow enough to read as a drift, not a jump. */
|
||||
const val MOVE_MILLIS = 1_200
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ScreenSaverTheme
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
|
||||
/** Near the bottom of the panel's range; not 0, which some panels take as "off". */
|
||||
private const val NIGHT_BRIGHTNESS = 0.01f
|
||||
|
||||
/**
|
||||
* Both hosts' display brightness for [nightMode]: almost nothing at night,
|
||||
* the user's own otherwise. The same window setting for the dream and the
|
||||
* activity, so the system's screen saver is no brighter at night than the
|
||||
* one opened from the menu.
|
||||
*/
|
||||
internal fun Window.applyScreenSaverBrightness(nightMode: Boolean) {
|
||||
attributes = attributes.apply {
|
||||
screenBrightness = if (nightMode) NIGHT_BRIGHTNESS else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What both hosts set as their content: [source]'s state in the screen
|
||||
* saver's theme. Collected with the host's lifecycle, so a stopped dream or
|
||||
* a backgrounded activity stops waking for the minute as well.
|
||||
*
|
||||
* [onNightModeChanged] hands the night-mode choice back to the host, which
|
||||
* owns the window and so the display's brightness — only once it is known:
|
||||
* both hosts start dim, so a night-mode screen saver never lights a dark room
|
||||
* for the moment its settings take to load. Until the settings and the
|
||||
* appearance have both arrived the screen stays black, rather than showing a
|
||||
* frame in colours that are about to change.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverRoot(source: ScreenSaverSource, onNightModeChanged: (Boolean) -> Unit) {
|
||||
val appearance by source.appearance.collectAsStateWithLifecycle<Appearance?>(initialValue = null)
|
||||
val state by remember(source) { source.state() }.collectAsStateWithLifecycle(initialValue = null)
|
||||
val nightMode = state?.settings?.nightMode
|
||||
LaunchedEffect(nightMode) { nightMode?.let(onNightModeChanged) }
|
||||
|
||||
ScreenSaverTheme(nightMode = nightMode ?: true, dynamicColor = appearance?.dynamicColor ?: false) {
|
||||
val ready = state
|
||||
if (ready != null && appearance != null) {
|
||||
ScreenSaverContent(ready)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background))
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The screen saver's settings as Android opens them: the gear next to
|
||||
* Clockula in Settings → Display → Screen saver (`android:settingsActivity`
|
||||
* in `res/xml/screen_saver_info.xml`). The same rows as Settings → Clock,
|
||||
* in the app's own theme.
|
||||
*
|
||||
* Exported because only an exported activity can be started by the Settings
|
||||
* app; it takes no extras and does nothing a user could not do in-app.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ScreenSaverSettingsActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
|
||||
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
|
||||
// The bars follow the app's resolved theme, not the device's
|
||||
// night flag, as MainActivity's do.
|
||||
LaunchedEffect(darkTheme) {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
||||
)
|
||||
}
|
||||
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
|
||||
CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) {
|
||||
ScreenSaverSettingsSection(inApp = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The screen saver's settings as a run of grouped rows: its style, night
|
||||
* mode, and a way to see it now. [inApp] — Settings → Clock — puts them
|
||||
* under their own header, and adds a last row into Android's own screen
|
||||
* saver settings, where Clockula is chosen and its start condition set; the
|
||||
* system's settings screen opens this one, so there both are left out.
|
||||
* Nothing is drawn, header included, until the settings have been read.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScreenSaverSettingsSection(
|
||||
inApp: Boolean,
|
||||
viewModel: ScreenSaverSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
val current = settings ?: return
|
||||
val context = LocalContext.current
|
||||
var showStyle by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (inApp) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.screen_saver_title))
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_style),
|
||||
summary = styleLabel(current.style),
|
||||
position = Position.Top,
|
||||
onClick = { showStyle = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_night_mode),
|
||||
summary = stringResource(R.string.screen_saver_night_mode_hint),
|
||||
position = Position.Middle,
|
||||
trailing = { Switch(checked = current.nightMode, onCheckedChange = viewModel::onNightModeChanged) },
|
||||
onClick = { viewModel.onNightModeChanged(!current.nightMode) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_preview),
|
||||
position = if (inApp) Position.Middle else Position.Bottom,
|
||||
onClick = { openScreenSaver(context) },
|
||||
)
|
||||
if (inApp) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.screen_saver_system_settings),
|
||||
summary = stringResource(R.string.screen_saver_system_settings_hint),
|
||||
position = Position.Bottom,
|
||||
onClick = { openSystemScreenSaverSettings(context) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showStyle) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.screen_saver_style),
|
||||
options = ScreenSaverStyle.entries.toList(),
|
||||
selected = current.style,
|
||||
label = { styleLabel(it) },
|
||||
onSelect = viewModel::onStyleSelected,
|
||||
onDismiss = { showStyle = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun styleLabel(style: ScreenSaverStyle): String = stringResource(
|
||||
when (style) {
|
||||
ScreenSaverStyle.DIGITAL -> R.string.screen_saver_style_digital
|
||||
ScreenSaverStyle.ANALOG -> R.string.screen_saver_style_analog
|
||||
},
|
||||
)
|
||||
|
||||
/** The app's own full-screen screen saver ([ScreenSaverActivity]). */
|
||||
internal fun openScreenSaver(context: Context) {
|
||||
context.startActivity(Intent(context, ScreenSaverActivity::class.java))
|
||||
}
|
||||
|
||||
/** Android's screen saver settings. Some builds leave the screen out; then the row does nothing rather than crash. */
|
||||
private fun openSystemScreenSaverSettings(context: Context) {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_DREAM_SETTINGS)) }
|
||||
}
|
||||
+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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsSection
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The clock tab's settings: the home zone, and whether the tab shows seconds.
|
||||
* The clock tab's settings: the home zone, whether the tab shows seconds and
|
||||
* the automatic local clock. Then the screen saver's: it is a clock face, and
|
||||
* the settings Android's own screen saver screen opens are the same rows.
|
||||
* The zone picker itself is ViewModel state, drawn by the hub.
|
||||
*/
|
||||
@Composable
|
||||
@@ -68,5 +71,7 @@ internal fun ClockSettingsScreen(
|
||||
},
|
||||
onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) },
|
||||
)
|
||||
|
||||
ScreenSaverSettingsSection(inApp = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Nightlight
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.screensaver.openScreenSaver
|
||||
|
||||
/**
|
||||
* Every tab's top-bar overflow, as Google Clock's: the screen saver, then
|
||||
* Settings. The screen saver opens directly — it is an activity of its own,
|
||||
* not a destination in the shell's graph.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ShellOverflowMenu(onOpenSettings: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.MoreVert,
|
||||
contentDescription = stringResource(R.string.menu_more_options),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.screen_saver_title)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Nightlight, contentDescription = null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
openScreenSaver(context)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_title)) },
|
||||
leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onOpenSettings()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.StopwatchState
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
@@ -63,12 +60,7 @@ fun StopwatchScreen(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.clockula.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
|
||||
/**
|
||||
* The screen saver's theme: always Clockula's dark scheme — wallpaper colours
|
||||
* when [dynamicColor] — over true black, because the screen saver runs in a
|
||||
* dark room more often than not and black pixels on an OLED panel are off.
|
||||
*
|
||||
* [nightMode] dims every role the face draws with towards black, so the
|
||||
* faces keep their usual tokens (the analog face is the world clock's own
|
||||
* `WorldClockFace`) and simply come out very dim. The window's brightness is
|
||||
* turned down by the host as well; the two together are what makes the
|
||||
* night mode readable without lighting the room.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ScreenSaverTheme(
|
||||
nightMode: Boolean,
|
||||
dynamicColor: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ClockulaTheme(darkTheme = true, dynamicColor = dynamicColor) {
|
||||
val base = MaterialTheme.colorScheme
|
||||
val scheme = remember(base, nightMode) { base.forScreenSaver(nightMode) }
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = scheme,
|
||||
typography = MaterialTheme.typography,
|
||||
shapes = MaterialTheme.shapes,
|
||||
motionScheme = MaterialTheme.motionScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of each role survives night mode: enough to read across a dark bedroom, little enough not to light it. */
|
||||
private const val NIGHT_LEVEL = 0.32f
|
||||
|
||||
private fun ColorScheme.forScreenSaver(nightMode: Boolean): ColorScheme {
|
||||
val black = copy(background = Color.Black, surface = Color.Black)
|
||||
if (!nightMode) return black
|
||||
fun Color.dim(): Color = lerp(Color.Black, this, NIGHT_LEVEL)
|
||||
return black.copy(
|
||||
primary = primary.dim(),
|
||||
onSurface = onSurface.dim(),
|
||||
onSurfaceVariant = onSurfaceVariant.dim(),
|
||||
// The analog face's dial and marks.
|
||||
secondaryContainer = secondaryContainer.dim(),
|
||||
onSecondaryContainer = onSecondaryContainer.dim(),
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -25,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
|
||||
@@ -59,12 +58,7 @@ fun TimersScreen(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
// Only over a list: on an empty tab the keypad is already on
|
||||
|
||||
@@ -44,6 +44,10 @@ import java.time.LocalTime
|
||||
* Scheme tokens only, no literal colour. The face is hidden from screen
|
||||
* readers: the time readout under it says the same time as text, so
|
||||
* describing the face as well would read the time twice.
|
||||
*
|
||||
* The screen saver reuses it at its own size (a [modifier] size wins over
|
||||
* the hero size) and without the second hand: it redraws once a minute, and
|
||||
* a second hand that only moved once a minute would be a broken one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -41,6 +40,7 @@ import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.worldclock.WorldClocks
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberLocalTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
@@ -102,12 +102,7 @@ fun WorldClockScreen(
|
||||
contentDescription = stringResource(R.string.world_clock_home_action),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.settings_title),
|
||||
)
|
||||
}
|
||||
ShellOverflowMenu(onOpenSettings = onOpenSettings)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val canAdd = loaded?.canAdd != false
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
|
||||
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</string>
|
||||
<string name="settings_section_clock">Clock</string>
|
||||
<string name="settings_clock_subtitle">Home time zone, seconds and local clock</string>
|
||||
<string name="settings_clock_subtitle">Home time zone, seconds, local clock and screen saver</string>
|
||||
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
|
||||
<string name="settings_home_zone">Home time zone</string>
|
||||
<string name="settings_world_clock_seconds">Display time with seconds</string>
|
||||
@@ -360,6 +360,20 @@
|
||||
<string name="settings_automatic_local_clock">Automatic local clock</string>
|
||||
<string name="settings_automatic_local_clock_hint">While you’re somewhere the time differs from home, show the local time under the home clock.</string>
|
||||
<string name="settings_automatic_local_clock_needs_home">Needs a home time zone. Home follows the device now, so it’s always local time.</string>
|
||||
<string name="screen_saver_title">Screen saver</string>
|
||||
<string name="screen_saver_style">Clock style</string>
|
||||
<string name="screen_saver_style_digital">Digital</string>
|
||||
<string name="screen_saver_style_analog">Analog</string>
|
||||
<string name="screen_saver_night_mode">Night mode</string>
|
||||
<string name="screen_saver_night_mode_hint">Very dim display, for dark rooms</string>
|
||||
<string name="screen_saver_preview">Open screen saver</string>
|
||||
<string name="screen_saver_system_settings">Choose when it starts</string>
|
||||
<string name="screen_saver_system_settings_hint">Pick Clockula as the screen saver in Android\'s settings</string>
|
||||
<!-- Read by a screen reader for the alarm line on the screen saver; %1$s is the day and time, "Tue 7:00 AM". -->
|
||||
<string name="screen_saver_next_alarm">Next alarm: %1$s</string>
|
||||
<!-- The screen saver's next-alarm line: %1$s is the day ("Tue", or "Thu, Oct 29" a week or more away), %2$s the time ("7:00 AM"). -->
|
||||
<string name="screen_saver_next_alarm_when">%1$s %2$s</string>
|
||||
<string name="menu_more_options">More options</string>
|
||||
<string name="settings_self_check">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_title">Why might my alarm not ring?</string>
|
||||
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
title-less so nothing flashes on the way through (M9 D3). -->
|
||||
<style name="Theme.Clockula.Invisible" parent="android:Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- The screen saver activity: black from the first frame, so opening it
|
||||
in a dark room never flashes the light window background. -->
|
||||
<style name="Theme.Clockula.ScreenSaver" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
|
||||
<!-- An hour numeral on the analog widget's dial (`layout/widget_analog_*.xml`).
|
||||
Its text size and inset are overridden per widget size at runtime. -->
|
||||
<style name="WidgetAnalogNumeral">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The screen saver's entry in Settings → Display → Screen saver: the gear
|
||||
beside it opens ScreenSaverSettingsActivity. The class name alone, with
|
||||
no "package/" prefix, so Settings pairs it with the installed build's own
|
||||
package (the debug and releaseTest builds add an applicationId suffix). -->
|
||||
<dream xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:settingsActivity="de.jeanlucmakiola.clockula.ui.screensaver.ScreenSaverSettingsActivity" />
|
||||
@@ -31,7 +31,7 @@ class ManifestRulesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators and the six widget receivers are exported`() {
|
||||
fun `exactly the launcher and its two icon aliases, the door, the six widget configurators, the six widget receivers and the screen saver are exported`() {
|
||||
val exported = Regex("<(activity|service|receiver|provider)\\b[\\s\\S]*?(?:/>|</\\1>)")
|
||||
.findAll(text)
|
||||
.map { it.value }
|
||||
@@ -47,7 +47,10 @@ class ManifestRulesTest {
|
||||
// timer widgets' colour screens) are exported for the same reason —
|
||||
// only the launcher can start an `APPWIDGET_CONFIGURE` activity
|
||||
// cross-package — and guarded to that one action by `the six
|
||||
// widget configurators answer only APPWIDGET_CONFIGURE` below.
|
||||
// widget configurators answer only APPWIDGET_CONFIGURE` below. The
|
||||
// screen saver's dream is bound by the system's dream manager and its
|
||||
// settings screen started by the Settings app, both from another
|
||||
// process; `the screen saver is bound only by the system` guards them.
|
||||
assertThat(exported).containsExactly(
|
||||
".MainActivity",
|
||||
".BloomIconAlias",
|
||||
@@ -65,9 +68,31 @@ class ManifestRulesTest {
|
||||
".widget.AnalogClockWidgetReceiver",
|
||||
".widget.StopwatchWidgetReceiver",
|
||||
".widget.QuickTimerWidgetReceiver",
|
||||
".ui.screensaver.ClockulaDreamService",
|
||||
".ui.screensaver.ScreenSaverSettingsActivity",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #25: the dream answers only the platform's DreamService action and
|
||||
* demands BIND_DREAM_SERVICE, which only the system holds; its settings
|
||||
* screen answers no intent at all, so it is reachable only by name.
|
||||
*/
|
||||
@Test
|
||||
fun `the screen saver is bound only by the system`() {
|
||||
val dream = Regex("<service\\b[\\s\\S]*?</service>").findAll(text)
|
||||
.map { it.value }
|
||||
.first { it.contains("android:name=\".ui.screensaver.ClockulaDreamService\"") }
|
||||
val actions = Regex("<action\\s+android:name=\"([^\"]+)\"").findAll(dream)
|
||||
.map { it.groupValues[1] }
|
||||
.toList()
|
||||
|
||||
assertThat(actions).containsExactly("android.service.dreams.DreamService")
|
||||
assertThat(dream).contains("android:permission=\"android.permission.BIND_DREAM_SERVICE\"")
|
||||
assertThat(activityBlock(".ui.screensaver.ScreenSaverSettingsActivity")).doesNotContain("<intent-filter")
|
||||
assertThat(activityBlock(".ui.screensaver.ScreenSaverActivity")).contains("android:exported=\"false\"")
|
||||
}
|
||||
|
||||
/**
|
||||
* M11: the mirror of `the six widget receivers answer only
|
||||
* APPWIDGET_UPDATE` for the six widget config activities — being
|
||||
|
||||
@@ -15,6 +15,8 @@ import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -76,6 +78,7 @@ class BackupCodecTest {
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
worldClockSeconds = true,
|
||||
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
bedtime = BedtimeSettings(
|
||||
enabled = true,
|
||||
bedtime = TimeOfDay(23, 15),
|
||||
@@ -112,6 +115,7 @@ class BackupCodecTest {
|
||||
defaults = ClockDefaults(),
|
||||
timerPresets = emptyList(),
|
||||
worldClockSeconds = false,
|
||||
screenSaver = ScreenSaverSettings(),
|
||||
),
|
||||
)
|
||||
val decoded = BackupCodec.decode(BackupCodec.encode(original))
|
||||
@@ -488,6 +492,22 @@ class BackupCodecTest {
|
||||
assertThat(BackupCodec.encode(sampleContent())).contains("\"automatic_local_clock\": false")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before the screen saver restores its defaults`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognised screen saver style degrades rather than rejecting`() {
|
||||
val json = minimalDocument().replace(
|
||||
"\"settings\": {}",
|
||||
"\"settings\": {\"screen_saver_style\": \"HOLOGRAM\", \"screen_saver_night_mode\": true}",
|
||||
)
|
||||
val settings = (BackupCodec.decode(json) as BackupResult.Success).content.settings
|
||||
assertThat(settings.screenSaver).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.DIGITAL, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty preset list is honoured as all removed`() {
|
||||
val json = minimalDocument().replace("\"settings\": {}", "\"settings\": {\"timer_presets\": []}")
|
||||
|
||||
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
|
||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
|
||||
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
@@ -95,6 +97,7 @@ class SettingsPrefsTest {
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
worldClockSeconds = false,
|
||||
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -110,6 +113,18 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
|
||||
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen saver starts digital with night mode off, and both choices round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings())
|
||||
|
||||
prefs.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
|
||||
prefs.setScreenSaverNightMode(true)
|
||||
|
||||
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.clockula.domain.screensaver
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
class ScreenSaverAlarmDayTest {
|
||||
|
||||
/** A Thursday. */
|
||||
private val today = LocalDate.parse("2026-10-22")
|
||||
|
||||
@Test
|
||||
fun `today and the next six days are named by their weekday alone`() {
|
||||
for (days in 0L..6L) {
|
||||
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(days), today)).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week away is the same weekday as today, so it carries its date`() {
|
||||
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(7), today)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a skipped weekly alarm two weeks away carries its date`() {
|
||||
assertThat(ScreenSaverAlarmDay.weekdayIsEnough(today.plusDays(14), today)).isFalse()
|
||||
}
|
||||
}
|
||||
+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,140 @@
|
||||
package de.jeanlucmakiola.clockula.ui.screensaver
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
|
||||
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle
|
||||
import de.jeanlucmakiola.clockula.domain.time.ClockChanges
|
||||
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.NEW_YORK
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Issue #25: the screen saver's source over the **real** alarm engine and a
|
||||
* real DataStore. It wakes on the minute and on a clock change, and nowhere
|
||||
* else, so every test drives the scheduler by hand rather than letting it
|
||||
* run to idle: an idle scheduler would skip through minute after minute of
|
||||
* virtual time.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ScreenSaverSourceTest {
|
||||
|
||||
private class FakeClockChanges : ClockChanges {
|
||||
val events = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
override fun changes(): Flow<Unit> = events
|
||||
}
|
||||
|
||||
private class FakeHourFormat(var is24: Boolean = true) : HourFormat {
|
||||
override fun is24Hour(): Boolean = is24
|
||||
}
|
||||
|
||||
private class Fixture(val harness: AlarmEngineHarness) {
|
||||
val changes = FakeClockChanges()
|
||||
val hourFormat = FakeHourFormat()
|
||||
val source = ScreenSaverSource(
|
||||
settings = harness.settings,
|
||||
alarmEngine = harness.engine,
|
||||
wallClock = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
clockChanges = changes,
|
||||
hourFormat = hourFormat,
|
||||
)
|
||||
val states = mutableListOf<ScreenSaverUiState>()
|
||||
}
|
||||
|
||||
/** Wednesday 10 June 2026, half a minute past five in Berlin. */
|
||||
private val start = instantAt(BERLIN, "2026-06-10T05:00:30")
|
||||
|
||||
private fun TestScope.collecting(tempDir: Path): Fixture {
|
||||
val fixture = Fixture(alarmEngineHarness(tempDir, now = start))
|
||||
backgroundScope.launch { fixture.source.state().toList(fixture.states) }
|
||||
runCurrent()
|
||||
return fixture
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first frame is the current minute, the settings and the next alarm`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
fixture.harness.givenAlarm(id = 1L, hour = 7, minute = 15, label = "Up")
|
||||
runCurrent()
|
||||
|
||||
val state = fixture.states.last()
|
||||
assertThat(state.time).isEqualTo(LocalDateTime.parse("2026-06-10T05:00"))
|
||||
assertThat(state.settings).isEqualTo(ScreenSaverSettings())
|
||||
assertThat(state.use24Hour).isTrue()
|
||||
assertThat(state.position).isEqualTo(ScreenSaverDrift.positionAt(start))
|
||||
assertThat(state.nextAlarm.hasAlarm).isTrue()
|
||||
assertThat(state.nextAlarm.hour).isEqualTo(7)
|
||||
assertThat(state.nextAlarm.minute).isEqualTo(15)
|
||||
assertThat(state.nextAlarm.dayOfWeek).isEqualTo(DayOfWeek.WEDNESDAY)
|
||||
assertThat(state.nextAlarm.label).isEqualTo("Up")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no alarm reads as no alarm`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
assertThat(fixture.states.last().nextAlarm.hasAlarm).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it sleeps until the minute changes, then moves on to it`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
val before = fixture.states.size
|
||||
|
||||
fixture.harness.wallClock.instant = start + 29.seconds
|
||||
advanceTimeBy(29.seconds)
|
||||
runCurrent()
|
||||
assertThat(fixture.states).hasSize(before)
|
||||
|
||||
fixture.harness.wallClock.instant = start + 30.seconds
|
||||
advanceTimeBy(1.seconds)
|
||||
runCurrent()
|
||||
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-10T05:01"))
|
||||
assertThat(fixture.states.last().position).isEqualTo(ScreenSaverDrift.positionAt(start + 30.seconds))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock change is picked up at once, not at the next minute`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
fixture.harness.zones.zone = NEW_YORK
|
||||
fixture.hourFormat.is24 = false
|
||||
fixture.changes.events.tryEmit(Unit)
|
||||
runCurrent()
|
||||
|
||||
// 05:00 in Berlin is 23:00 the evening before in New York.
|
||||
assertThat(fixture.states.last().time).isEqualTo(LocalDateTime.parse("2026-06-09T23:00"))
|
||||
assertThat(fixture.states.last().use24Hour).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settings change redraws without waiting for the minute`(@TempDir tempDir: Path) = runTest {
|
||||
val fixture = collecting(tempDir)
|
||||
|
||||
fixture.harness.settings.setScreenSaverStyle(ScreenSaverStyle.ANALOG)
|
||||
fixture.harness.settings.setScreenSaverNightMode(true)
|
||||
runCurrent()
|
||||
|
||||
assertThat(fixture.states.last().settings)
|
||||
.isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -90,7 +90,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/ring/` | what both ring paths share: `RingAudio` (the ramp tick, the volume floor), `VolumeRamp`, `AudioSourcePolicy`/`RingtoneSourceKind`, `RingFallbackPolicy`, `VibrationPattern` |
|
||||
| `domain/stopwatch/` | the stopwatch path's pure half: `StopwatchReadings` (the one reading the pill, the shade and the tab share), `LapStats` (best and worst), `StopwatchLaps` (the lap cap), `StopwatchNotificationPolicy` |
|
||||
| `domain/timer/` | the timer path's pure half: `TimerReadings` (the app's one timer precedence), `TimerExpiry` (the sweep and the slot's value), `TimerRingPolicy`, `TimerNotificationPolicy`, `TimerPresets`, `TimerDurationEntry`, `TimerSettings`, `TimerRing` |
|
||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker` |
|
||||
| `domain/time/` | `WallClock`, `ElapsedRealtimeClock`, `ZoneProvider` (`current()` and, since M8, `available()`, the device's tzdata), `BootId`, `Ticker`, and for the screen saver `ClockChanges` (a time, zone or 12/24-hour change while collected), `HourFormat` and `MinuteBoundary` |
|
||||
| `domain/worldclock/` | the world clock's pure half (M8): `ZoneCatalog` (`ZoneEntry`, the pickable-id filter, the city fallback, the sort), `ZoneSearch` (folding and word-prefix matching), `ZoneComparison` (the offset and day arithmetic, `OffsetLabel`/`DayLabel`, `ZoneOffsetFormat`), `WorldClocks` (`MAX`, the move arithmetic) + `HomeZone` + `LocalClock` (when the device's zone gets a temporary row), `AnalogFace` (hand and tick angles) + `DayNight` (the day fraction) |
|
||||
| `domain/interop/` | the `android.provider.AlarmClock` boundary's pure half (M9): `AlarmClockContract` (the platform's vocabulary as literals; the only file in `src/main` that may hold them), `IntentExtras` (a Bundle modelled honestly), `InteropLimits`, `InteropText` (the label and ringtone sanitisers), `InteropDeepLinks`, `AlarmClockRequest`/`AlarmSpec`/`AlarmSearch`/`InteropOutcome`, `AlarmClockRequests` (the validator) and `AlarmMatching` (which alarms a search names) |
|
||||
| `domain/text/` | `TextFolding` (M9): NFD-fold, strip combining marks, lowercase through `Locale.ROOT`, `containsFolded`. One copy, shared by the zone search and the label search |
|
||||
@@ -99,6 +99,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
|
||||
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
|
||||
| `domain/bedtime/` | the bedtime schedule's pure half (issue #1): `BedtimeSettings`/`BedtimeEffects`/`BedtimeLimits` and `BedtimeSchedule` (the window a night resolves to, the active window, the due reminder, the next event). §18 |
|
||||
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode), `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone, and `ScreenSaverAlarmDay`, which dates a next alarm a week or more away rather than naming its weekday alone |
|
||||
| `domain/widget/` | the three widget presenters (M10), `NextAlarmWidgetModel`/`TimerWidgetModel`/`ClockWidgetModel`: selection and formatting only, so a widget's content is unit-tested without Glance |
|
||||
| `data/db/` | `ClockulaDatabase` (`@Database` v4, `exportSchema = true`) and `Migrations` |
|
||||
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |
|
||||
@@ -106,9 +107,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `data/worldclocks/` | `WorldClockEntity`, `WorldClockDao`, `WorldClockMapper`, `WorldClockRepository(+Impl)`, unchanged since M2; M8 is the milestone that finally calls all of it |
|
||||
| `data/zones/` | the ICU seam (M8): `ZoneNames`, `IcuZoneNames` (the only `android.icu` file in the app) and `ZoneDirectory`, the locale-keyed catalog cache and the DST-keyed display-name memo |
|
||||
| `data/stopwatch/` | `LapEntity`, `LapDao` (one `COUNT(*)` added in M7), `LapMapper`, `StopwatchStateStore`, `StopwatchRepository(+Impl)` |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1) |
|
||||
| `data/prefs/` | `SettingsPrefs`, `ClockPrefs`, `StopwatchPrefs`, `TimerPrefs`, `UiPrefs`, `BedtimePrefs` (issue #1), `ScreenSaverPrefs` (issue #25) |
|
||||
| `data/ringtones/` | the two ringtone seams, `RingtoneCatalog` (the bundled and device alarm sounds, titles, playability, a SAF grant) and `RingtonePreviewer`, plus their `System*` implementations, and `MediaLibrary`/`SystemMediaLibrary`, the picker's view of other apps' `MediaBrowserService` libraries |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker` |
|
||||
| `data/time/` | `SystemWallClock`, `SystemElapsedRealtimeClock`, `SystemZoneProvider`, `AndroidBootIdProvider`, `RealTicker`, `AndroidClockChanges` (a receiver for `TIME_SET`/`TIMEZONE_CHANGED` registered only while collected), `AndroidHourFormat` |
|
||||
| `data/di/` | `DataModule`, `DatabaseModule`, `RepositoryModule`, `TimeModule`, `RingtoneModule`, `ZoneModule` (M8; one `@Binds`, `ZoneNames → IcuZoneNames`), `BackupModule` (M10) |
|
||||
| `data/backup/` | the backup's data-layer half (M10): `BackupDto`/`BackupCodec` (the pure JSON wire format, `ignoreUnknownKeys`, strict on known fields), `BackupDao` (one `@Transaction` replace-all over alarms/timers/world clocks; no schema or version change), `BackupRepository(+Impl)`, mapping through the existing `AlarmMapper`/`TimerMapper`/`WorldClockMapper`, never a second copy |
|
||||
| `alarm/` | `AlarmEngine` and the four seams it talks to (`AlarmScheduler`, `AlarmCapabilities`, `RingCoordinator`, `AlarmNotifier`), plus `AlarmIntents` |
|
||||
@@ -140,8 +141,9 @@ kit module: the kit's roadmap keeps schedulers app-local.
|
||||
| `ui/stopwatch/` | the real Stopwatch tab (M7): the pure row builder (`StopwatchRows`) and its source, the ViewModel, the readout panel with its controls, the lap table and its column header, `StopwatchDefaults` |
|
||||
| `ui/worldclock/` | the real World clock tab (M8): the pure row builders (`WorldClockRows`, `ZonePickerRows`) and their state, `WorldClockSource`, the ViewModel, the tab, the analog face, the row and the zone picker, `WorldClockDefaults` |
|
||||
| `ui/ring/` | `AlarmRingActivity`, its ViewModel, `RingScreen` and the dismiss-challenge controls |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from a gear icon on every tab, with no owning tab of their own |
|
||||
| `ui/settings/` | the settings hub (M10): `SettingsScreen`/`SettingsViewModel` (appearance, alarm/timer/clock defaults, reliability, data, help), `SelfCheckScreen`/`SelfCheckViewModel`, `BackupScreen`/`BackupViewModel`, all reachable from every tab's overflow menu (`ui/shell/ShellOverflowMenu`, which also opens the screen saver), with no owning tab of their own |
|
||||
| `ui/bedtime/` | `BedtimeScreen`/`BedtimeViewModel` (issue #1), a nested destination reached from the settings hub, the bedtime reminder and the mode's page in system Settings, never a tab |
|
||||
| `ui/screensaver/` | the screen saver (issue #25), here rather than beside the widgets because both of its hosts host Compose: `ClockulaDreamService` (the system's dream; `DreamViewOwner` puts the lifecycle, view-model-store and saved-state owners a `ComposeView` needs on the dream window's decor view), `ScreenSaverActivity` (the same content full-screen, from the tabs' overflow menu, since an app cannot start a dream without a system permission), `ScreenSaverSource` (prefs + `AlarmEngine.upcoming()` + the clocks, waking once a minute on the minute and on a `ClockChanges`), the content (numerals or the world clock's `WorldClockFace` without its second hand, the date and the next alarm through `NextAlarmWidgetPresenter`), and the settings rows shared by Settings → Clock and `ScreenSaverSettingsActivity`, the system's gear. `ui/theme/ScreenSaverTheme.kt` is the dark scheme over black, dimmed towards black in night mode |
|
||||
| `ui/widget/` | the three widgets' `@Composable` Glance content (M10), the only Compose this milestone adds outside a tab; the `GlanceAppWidget`/`GlanceAppWidgetReceiver` classes themselves live in `widget/`, since they are manifest components, not UI. `ui/widget/config/` holds each widget's config activity; every widget has one, if only for its per-instance colours (`domain/widget/WidgetColors.kt`: System/Light/Dark and, on API 31+, wallpaper colours on/off). Glance content takes those through `WidgetTheme`; the plain-`RemoteViews` faces, readouts and chronometers through `WidgetPalette.kt`, which resolves the `widget_*` palette under a forced day/night (the app's own tones are `widget_app_*`) and sets concrete colours, or day/night pairs, on the views. The analog and digital clocks are re-coloured on API 31+ only, since below that `AnalogClock`'s hands and a shape's background tint can't be set through `RemoteViews` |
|
||||
|
||||
---
|
||||
@@ -618,6 +620,7 @@ Six slices:
|
||||
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
| Bedtime (issue #1) | `bedtime_enabled`, `bedtime_start_minutes`, `bedtime_wake_minutes`, `bedtime_days`, `bedtime_reminder_minutes`, `bedtime_effect_grayscale`, `bedtime_effect_dim_wallpaper`, `bedtime_effect_dark_theme`, `bedtime_effect_hide_aod`, `bedtime_applied_active`, `bedtime_rule_stale`, `bedtime_state_owed` | `BedtimePrefs`, surfaced as `SettingsPrefs.bedtime: Flow<BedtimeSettings>` and written as one record by `BedtimeEngine` only. Times clamp into a day, the reminder into 0–120 minutes, the days through `RepeatDays.fromMask`. The system rule's id is **not** stored (§18) |
|
||||
| Screen saver (issue #25) | `screen_saver_style`, `screen_saver_night_mode` | `ScreenSaverPrefs`, surfaced as `SettingsPrefs.screenSaver: Flow<ScreenSaverSettings>` and carried in the backup |
|
||||
|
||||
The stopwatch's *run* is a single record, so it lives in DataStore rather than
|
||||
as a one-row table (`PLAN.md` §5); its *laps* are a list, so they live in Room.
|
||||
@@ -1064,6 +1067,15 @@ three widget receivers, each with **only** the platform-required
|
||||
platform-delivered broadcast no other app can trigger by naming the action.
|
||||
M10 adds no `<uses-permission>`.
|
||||
|
||||
The screen saver (issue #25) adds `ui.screensaver.ClockulaDreamService`,
|
||||
exported with only the `android.service.dreams.DreamService` filter and
|
||||
guarded by `BIND_DREAM_SERVICE`, which only the system holds; its
|
||||
`android.service.dream` meta-data (`res/xml/screen_saver_info.xml`) names
|
||||
`ScreenSaverSettingsActivity`, exported with no intent filter so the Settings
|
||||
app can open it by name; and the unexported `ScreenSaverActivity`. No
|
||||
`<uses-permission>` either: keeping the screen on is a window flag, not a
|
||||
wake lock.
|
||||
|
||||
---
|
||||
|
||||
## 10. What is not built yet
|
||||
|
||||
+7
-2
@@ -172,7 +172,9 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"dim_wallpaper": true,
|
||||
"dark_theme": true,
|
||||
"hide_always_on_display": false
|
||||
}
|
||||
},
|
||||
"screen_saver_style": "DIGITAL",
|
||||
"screen_saver_night_mode": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -186,7 +188,10 @@ is clamped rather than rejected: times into a day, the reminder into 0–120
|
||||
minutes, and a `days` of 0 (which the screen never allows) into every night. The id of the Do Not Disturb rule a device created is not part of it;
|
||||
the importing device creates its own (`docs/ARCHITECTURE.md` §18).
|
||||
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
|
||||
Field names mirror `ClockPrefs`'/`SettingsPrefs`'/`ScreenSaverPrefs`' own
|
||||
on-disk keys. A file from before the screen saver has neither
|
||||
`screen_saver_*` field and restores its defaults (digital, night mode off);
|
||||
an unknown style degrades to `DIGITAL` rather than rejecting the file. Older
|
||||
files may also carry `default_timer_duration_millis`, from a default timer
|
||||
length setting that has since been removed; it is ignored on import like any
|
||||
other unknown field. `volume_buttons_while_ringing` is one of
|
||||
|
||||
+5
-2
@@ -611,8 +611,11 @@ reliability soak across a few real nights. Then 1.0.0.
|
||||
|
||||
Deferred deliberately, in rough priority order:
|
||||
|
||||
- **Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.
|
||||
- ~~**Dock / screensaver mode**: a `DreamService` full-screen clock. Cheapest big
|
||||
win, and the surface people look at most.~~ Done (issue #25):
|
||||
`ClockulaDreamService` plus `ScreenSaverActivity` for the overflow menu's
|
||||
"Screen saver", digital or analog, with a dim night mode and a once-a-minute
|
||||
drift against burn-in (`ARCHITECTURE.md` §3, §6).
|
||||
- **Quick Settings tile**: timer control and next alarm from the shade.
|
||||
- 🚧 **Bedtime / sleep schedule** (issue #1): a first version exists as an
|
||||
opt-in screen under Settings, not a fifth tab. Clockula's own Do Not Disturb
|
||||
|
||||
Reference in New Issue
Block a user