diff --git a/CHANGELOG.md b/CHANGELOG.md index 6499057..832aa80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c64279..15f4649 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -285,6 +285,51 @@ android:name=".bedtime.receiver.BedtimeReceiver" android:exported="false" /> + + + + + + + + + + + + + + + ? = 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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt index b7e5a05..970af0b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/TimeModule.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt new file mode 100644 index 0000000..0b9c32a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ScreenSaverPrefs.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.clockula.data.prefs + +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import de.jeanlucmakiola.floret.prefs.Pref +import de.jeanlucmakiola.floret.prefs.booleanPref +import de.jeanlucmakiola.floret.prefs.enumPref + +/** The screen saver's on-disk keys, surfaced as `SettingsPrefs.screenSaver`. */ +object ScreenSaverPrefs { + + val style: Pref = enumPref("screen_saver_style", ScreenSaverStyle.DIGITAL) + + val nightMode: Pref = booleanPref("screen_saver_night_mode", false) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index 42d0208..1555002 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -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 = combine( + store.flow(ScreenSaverPrefs.style), + store.flow(ScreenSaverPrefs.nightMode), + ::ScreenSaverSettings, + ).distinctUntilChanged() + + suspend fun setScreenSaverStyle(style: ScreenSaverStyle) = store.set(ScreenSaverPrefs.style, style) + + suspend fun setScreenSaverNightMode(enabled: Boolean) = store.set(ScreenSaverPrefs.nightMode, enabled) + // --- first-launch bookkeeping (M4) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt index 2397192..f5209ee 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/time/AndroidClockChanges.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt index 55bb73d..0d48ba7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/backup/BackupContent.kt @@ -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, /** 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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt new file mode 100644 index 0000000..36b6fd5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverAlarmDay.kt @@ -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)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt new file mode 100644 index 0000000..f92c1d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverDrift.kt @@ -0,0 +1,47 @@ +package de.jeanlucmakiola.clockula.domain.screensaver + +import kotlin.time.Instant + +/** + * Where the screen saver's clock sits, so a face left up all night never + * burns into an OLED panel: it moves to a new spot once a minute, as AOSP's + * DeskClock screen saver does. + * + * A position is a pair of fractions of the *free* space — the screen minus + * the clock itself — so `0f` is flush with the left/top edge and `1f` with + * the right/bottom one, at any screen and clock size. Derived from the minute + * alone rather than from a random source, so the clock lands on the same spot + * however often the minute is re-read (a recomposition, a time change) and + * moves only when the minute does. + */ +object ScreenSaverDrift { + + data class Position(val x: Float, val y: Float) + + /** The spot for the minute [now] falls in. Both fractions in `[0, 1]`. */ + fun positionAt(now: Instant): Position { + val minute = Math.floorDiv(now.epochSeconds, SECONDS_PER_MINUTE) + val bits = mix(minute) + return Position(x = fraction(bits ushr 40), y = fraction(bits ushr 16)) + } + + /** The low 24 bits of [bits] as a fraction in `[0, 1]`. */ + private fun fraction(bits: Long): Float = (bits and MASK_24).toFloat() / MASK_24 + + /** + * SplitMix64's finaliser: consecutive minutes land on unrelated spots, + * where a plain modulo would walk the clock across the screen in a line. + */ + private fun mix(seed: Long): Long { + var z = seed + GOLDEN_GAMMA + z = (z xor (z ushr 30)) * MIX_1 + z = (z xor (z ushr 27)) * MIX_2 + return z xor (z ushr 31) + } + + private const val SECONDS_PER_MINUTE = 60L + private const val MASK_24 = 0xFFFFFFL + private const val GOLDEN_GAMMA = -0x61c8864680b583ebL + private const val MIX_1 = -0x40a7b892e31b1a47L + private const val MIX_2 = -0x6b2fb644ecceee15L +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt new file mode 100644 index 0000000..2ff1cdb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/screensaver/ScreenSaverSettings.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.clockula.domain.screensaver + +/** The screen saver's two faces: the time as numerals, or the world clock's dial. */ +enum class ScreenSaverStyle { DIGITAL, ANALOG } + +/** + * What the screen saver shows. [nightMode] is Google Clock's "very dim + * display, for dark rooms": dim colours on black and the display itself + * turned down, for a phone on a bedside table. + */ +data class ScreenSaverSettings( + val style: ScreenSaverStyle = ScreenSaverStyle.DIGITAL, + val nightMode: Boolean = false, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index 4bb48b0..984d94a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -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 } + +/** The device's 12/24-hour choice, read afresh on every call. */ +interface HourFormat { + fun is24Hour(): Boolean +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt new file mode 100644 index 0000000..e6bb542 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/MinuteBoundary.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.clockula.domain.time + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * How long a minute-resolution readout may sleep: until the next wall-clock + * minute begins, so it changes on the minute rather than up to a minute late, + * and wakes once a minute rather than on a fixed sub-second cadence. + */ +object MinuteBoundary { + + private const val MILLIS_PER_MINUTE = 60_000L + + /** + * From [now] to the start of the next minute, in `(0, 1 min]`: exactly on a + * boundary is a whole minute away, never zero, so a caller looping on it + * cannot spin. + */ + fun untilNext(now: Instant): Duration { + val intoMinute = Math.floorMod(now.toEpochMilliseconds(), MILLIS_PER_MINUTE) + return (MILLIS_PER_MINUTE - intoMinute).milliseconds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index cd5d757..ca2df9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt index 3abe881..4646e90 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/AlarmTimeFormatter.kt @@ -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 { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt new file mode 100644 index 0000000..04b3d1f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ClockulaDreamService.kt @@ -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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt new file mode 100644 index 0000000..0decb7e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/DreamViewOwner.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner + +/** + * The lifecycle, view-model store and saved-state registry a `ComposeView` + * expects to find on its window, for a window no `ComponentActivity` owns — + * a `DreamService`'s. Without them the `ComposeView` throws on attach. + * + * The lifecycle follows the dream: created with its window, resumed while it + * is dreaming, stopped when it stops, destroyed when the window goes. A + * stopped lifecycle is what makes `collectAsStateWithLifecycle` let go of the + * minute ticks, so a dream that is no longer shown costs nothing. + */ +internal class DreamViewOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { + + private val registry = LifecycleRegistry(this) + private val savedState = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle get() = registry + override val viewModelStore: ViewModelStore = ViewModelStore() + override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry + + /** Restores (nothing — a dream keeps no state across windows) and moves to CREATED, then installs itself on [root]. */ + fun create(root: View) { + savedState.performRestore(null) + registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + root.setViewTreeLifecycleOwner(this) + root.setViewTreeViewModelStoreOwner(this) + root.setViewTreeSavedStateRegistryOwner(this) + } + + fun start() { + registry.currentState = Lifecycle.State.RESUMED + } + + fun stop() { + if (registry.currentState.isAtLeast(Lifecycle.State.STARTED)) registry.currentState = Lifecycle.State.CREATED + } + + fun destroy() { + if (registry.currentState == Lifecycle.State.INITIALIZED) return + registry.currentState = Lifecycle.State.DESTROYED + viewModelStore.clear() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt new file mode 100644 index 0000000..e00bca6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverActivity.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt new file mode 100644 index 0000000..7866389 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverContent.kt @@ -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() { + /** + * 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?): 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 { + // 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt new file mode 100644 index 0000000..f1063f4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverRoot.kt @@ -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(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)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt new file mode 100644 index 0000000..6d68caf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsActivity.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt new file mode 100644 index 0000000..b9c7632 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsSection.kt @@ -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)) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt new file mode 100644 index 0000000..ff9ba23 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSettingsViewModel.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverStyle +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The screen saver's two settings, for both places they are edited: Settings + * → Clock, and the system's own screen saver settings ([ScreenSaverSettingsActivity]). + * Every write lands at once, as everywhere else in Settings. Null until the + * first read, so a row never shows a default it is about to change. + */ +@HiltViewModel +class ScreenSaverSettingsViewModel @Inject constructor( + private val settings: SettingsPrefs, +) : ViewModel() { + + val state: StateFlow = settings.screenSaver + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), null) + + fun onStyleSelected(style: ScreenSaverStyle) = viewModelScope.launch { settings.setScreenSaverStyle(style) } + + fun onNightModeChanged(enabled: Boolean) = viewModelScope.launch { settings.setScreenSaverNightMode(enabled) } + + private companion object { + const val SUBSCRIPTION_GRACE_MS = 5_000L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt new file mode 100644 index 0000000..603be5a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/screensaver/ScreenSaverSource.kt @@ -0,0 +1,95 @@ +package de.jeanlucmakiola.clockula.ui.screensaver + +import de.jeanlucmakiola.clockula.alarm.AlarmEngine +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverDrift +import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings +import de.jeanlucmakiola.clockula.domain.time.ClockChanges +import de.jeanlucmakiola.clockula.domain.time.HourFormat +import de.jeanlucmakiola.clockula.domain.time.MinuteBoundary +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.domain.time.ZoneProvider +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetModel +import de.jeanlucmakiola.clockula.domain.widget.NextAlarmWidgetPresenter +import de.jeanlucmakiola.floret.prefs.Appearance +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onStart +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.toJavaInstant + +/** One frame of the screen saver: everything it draws, resolved to the minute. */ +internal data class ScreenSaverUiState( + val settings: ScreenSaverSettings, + /** The device's local time, truncated to the minute. */ + val time: LocalDateTime, + val use24Hour: Boolean, + val nextAlarm: NextAlarmWidgetModel, + val position: ScreenSaverDrift.Position, +) + +/** + * Prefs + the alarm engine + the clocks → the screen saver's state, for both + * of its hosts: the system's `ClockulaDreamService` and the app's own + * `ScreenSaverActivity`. + * + * It wakes **once a minute**, on the minute ([MinuteBoundary]) — the face + * shows no seconds, so a sub-second ticker would only burn battery on a + * screen left up all night — and at once when the device's clock is changed + * ([ClockChanges]: the time, the zone, or 12/24-hour). The next alarm is the + * next-alarm widget's own model ([NextAlarmWidgetPresenter]), re-resolved on + * every wake so an alarm that just rang moves on to its next occurrence. + */ +@Singleton +internal class ScreenSaverSource @Inject constructor( + private val settings: SettingsPrefs, + private val alarmEngine: AlarmEngine, + private val wallClock: WallClock, + private val zones: ZoneProvider, + private val clockChanges: ClockChanges, + private val hourFormat: HourFormat, +) { + + val appearance: Flow = settings.appearance + + fun state(): Flow = + combine(settings.screenSaver, alarmEngine.upcoming(minuteTicks())) { screenSaver, upcoming -> + val now = wallClock.now() + val zone = zones.current() + ScreenSaverUiState( + settings = screenSaver, + time = ZonedDateTime.ofInstant(now.toJavaInstant(), zone) + .toLocalDateTime() + .truncatedTo(ChronoUnit.MINUTES), + use24Hour = hourFormat.is24Hour(), + nextAlarm = NextAlarmWidgetPresenter.present(upcoming, now, zone), + position = ScreenSaverDrift.positionAt(now), + ) + }.distinctUntilChanged() + + /** + * Emits at once, then at every minute boundary. A clock change restarts + * the wait, since a `delay` measured against the old time would land on + * the wrong boundary. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun minuteTicks(): Flow = clockChanges.changes() + .onStart { emit(Unit) } + .flatMapLatest { + flow { + while (true) { + emit(Unit) + delay(MinuteBoundary.untilNext(wallClock.now())) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt index 4f6e3ca..d23bb78 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt @@ -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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt new file mode 100644 index 0000000..09e1f00 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellOverflowMenu.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Nightlight +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.ui.screensaver.openScreenSaver + +/** + * Every tab's top-bar overflow, as Google Clock's: the screen saver, then + * Settings. The screen saver opens directly — it is an activity of its own, + * not a destination in the shell's graph. + */ +@Composable +internal fun ShellOverflowMenu(onOpenSettings: () -> Unit) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.menu_more_options), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.screen_saver_title)) }, + leadingIcon = { Icon(Icons.Rounded.Nightlight, contentDescription = null) }, + onClick = { + expanded = false + openScreenSaver(context) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.settings_title)) }, + leadingIcon = { Icon(Icons.Rounded.Settings, contentDescription = null) }, + onClick = { + expanded = false + onOpenSettings() + }, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt index be83ee5..9538565 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt @@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.StopwatchState import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -63,12 +60,7 @@ fun StopwatchScreen( modifier = modifier, scrollable = false, actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, ) { Column( diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt new file mode 100644 index 0000000..579567e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/theme/ScreenSaverTheme.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.clockula.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +/** + * The screen saver's theme: always Clockula's dark scheme — wallpaper colours + * when [dynamicColor] — over true black, because the screen saver runs in a + * dark room more often than not and black pixels on an OLED panel are off. + * + * [nightMode] dims every role the face draws with towards black, so the + * faces keep their usual tokens (the analog face is the world clock's own + * `WorldClockFace`) and simply come out very dim. The window's brightness is + * turned down by the host as well; the two together are what makes the + * night mode readable without lighting the room. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenSaverTheme( + nightMode: Boolean, + dynamicColor: Boolean, + content: @Composable () -> Unit, +) { + ClockulaTheme(darkTheme = true, dynamicColor = dynamicColor) { + val base = MaterialTheme.colorScheme + val scheme = remember(base, nightMode) { base.forScreenSaver(nightMode) } + MaterialExpressiveTheme( + colorScheme = scheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + motionScheme = MaterialTheme.motionScheme, + content = content, + ) + } +} + +/** How much of each role survives night mode: enough to read across a dark bedroom, little enough not to light it. */ +private const val NIGHT_LEVEL = 0.32f + +private fun ColorScheme.forScreenSaver(nightMode: Boolean): ColorScheme { + val black = copy(background = Color.Black, surface = Color.Black) + if (!nightMode) return black + fun Color.dim(): Color = lerp(Color.Black, this, NIGHT_LEVEL) + return black.copy( + primary = primary.dim(), + onSurface = onSurface.dim(), + onSurfaceVariant = onSurfaceVariant.dim(), + // The analog face's dial and marks. + secondaryContainer = secondaryContainer.dim(), + onSecondaryContainer = onSecondaryContainer.dim(), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 0741321..4681572 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset +import de.jeanlucmakiola.clockula.ui.shell.ShellOverflowMenu import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.identity.animateItemMotion @@ -59,12 +58,7 @@ fun TimersScreen( modifier = modifier, scrollable = false, actions = { - IconButton(onClick = onOpenSettings) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.settings_title), - ) - } + ShellOverflowMenu(onOpenSettings = onOpenSettings) }, floatingActionButton = { // Only over a list: on an empty tab the keypad is already on diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt index 204f998..92381d2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockFace.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index 9f5620b..77703f2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b718b48..0374d0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -352,7 +352,7 @@ How timers ring. A timer can still pick its own sound in its editor. Off. A timer set to Silent still vibrates Clock - Home time zone, seconds and local clock + Home time zone, seconds, local clock and screen saver World clocks show how far each city is from your home time zone. Home time zone Display time with seconds @@ -360,6 +360,20 @@ Automatic local clock While you’re somewhere the time differs from home, show the local time under the home clock. Needs a home time zone. Home follows the device now, so it’s always local time. + Screen saver + Clock style + Digital + Analog + Night mode + Very dim display, for dark rooms + Open screen saver + Choose when it starts + Pick Clockula as the screen saver in Android\'s settings + + Next alarm: %1$s + + %1$s %2$s + More options Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9230f70..686e75d 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,6 +9,14 @@ title-less so nothing flashes on the way through (M9 D3). --> +