fix(screensaver): keep both screen saver activities out of the app's task

The settings screen Android's screen saver list opens gets a task of its
own and stays out of recents, so it never lands in or clears the app's
task, and its bars follow the app's theme. The on-demand screen saver is
noHistory, so leaving it by Home does not bring the app back to it later,
and it only touches the keep-screen-on flag when charging starts or stops.

The next-alarm line uses the same 12/24-hour rule as the big readout,
joined to the day by a translatable string; the minute's crossfade shows
the old minute fading out rather than two copies of the new one.
This commit is contained in:
2026-10-22 08:39:07 +02:00
parent 23f2ee94ea
commit 4765b8e088
6 changed files with 47 additions and 22 deletions
+9 -2
View File
@@ -295,18 +295,25 @@
<!-- 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. -->
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. -->
app cannot start the system's dream itself. Not exported, and
no history: leaving it by Home or Recents finishes it, so the app
never comes back to a dim screen saver instead of its tabs. -->
<activity
android:name=".ui.screensaver.ScreenSaverActivity"
android:exported="false"
android:noHistory="true"
android:theme="@style/Theme.Clockula.ScreenSaver" />
<!-- Persists the per-app language on API < 33, where the platform
@@ -41,6 +41,9 @@ class ScreenSaverActivity : ComponentActivity() {
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)
@@ -79,6 +82,8 @@ class ScreenSaverActivity : ComponentActivity() {
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 {
@@ -59,7 +59,8 @@ import kotlin.math.roundToInt
* 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.
* a cut under reduce motion: the old minute fades out where it was as the
* new one fades in at its new spot.
*/
@Composable
internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier = Modifier) {
@@ -75,11 +76,11 @@ internal fun ScreenSaverContent(state: ScreenSaverUiState, modifier: Modifier =
// landscape on a bedside dock as it is upright in the hand.
val side = minOf(maxWidth, maxHeight)
Crossfade(
targetState = state.position,
targetState = state,
animationSpec = if (reduceMotion) snap() else tween(ScreenSaverDefaults.MOVE_MILLIS),
label = "drift",
) { position ->
Drifting(position) { Clock(state, side) }
) { shown ->
Drifting(shown.position) { Clock(shown, side) }
}
}
}
@@ -179,7 +180,8 @@ private fun AnalogTime(time: LocalTime, use24Hour: Boolean, locale: Locale, side
@Composable
private fun NextAlarm(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale) {
val label = remember(model, now.toLocalDate(), use24Hour, locale) { nextAlarmLabel(model, now, use24Hour, locale) }
val (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,
@@ -235,16 +237,17 @@ private fun dateLabel(now: LocalDateTime, locale: Locale): String =
/**
* The next alarm as the platform's own screen saver writes it: the short
* weekday and the time, "Tue 7:00 AM", laid out in the locale's own order.
* 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".
* 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 nextAlarmLabel(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): String {
val date = model.date ?: return ""
val day = if (ScreenSaverAlarmDay.weekdayIsEnough(date, now.toLocalDate())) "EEE" else "EEEMMMd"
val time = if (use24Hour) "HHmm" else "hmma"
return localizedDateFormatter(locale, day + time).format(date.atTime(model.hour, model.minute))
private fun nextAlarmParts(model: NextAlarmWidgetModel, now: LocalDateTime, use24Hour: Boolean, locale: Locale): Pair<String, String> {
val date = model.date ?: now.toLocalDate()
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 {
@@ -1,10 +1,13 @@
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
@@ -33,13 +36,18 @@ class ScreenSaverSettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val appearance by settingsPrefs.appearance.collectAsStateWithLifecycle(initialValue = Appearance())
ClockulaTheme(
darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme()),
dynamicColor = appearance.dynamicColor,
) {
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)
}
+2
View File
@@ -315,6 +315,8 @@
<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>
+1 -1
View File
@@ -98,7 +98,7 @@ kit module: the kit's roadmap keeps schedulers app-local.
| `domain/format/` | `ClockFormat` (`M:SS` / `H:MM:SS`, countdowns rounded up, elapsed truncated), `NextFire` (`NextFireLabel` + `NextFireFormat`), the alarm row's "in 9h 12m" as data, and `StopwatchFormat`, the hundredths readout, split into a major field and two digits so the fraction can be drawn smaller |
| `domain/backup/` | `BackupContent` (M10): the plain Kotlin model between `data/backup`'s JSON codec and the repository/engine; decoupled from the wire DTOs the same way the data layer is decoupled from Room |
| `domain/selfcheck/` | `SelfCheck` (M10): the "why might my alarm not ring?" rule table: pure functions over a `DeviceState` snapshot, each producing a `CheckResult` with a status and an optional deep-link `SelfCheckFix` |
| `domain/screensaver/` | the screen saver's pure half (issue #25): `ScreenSaverSettings` (style and night mode) and `ScreenSaverDrift`, the once-a-minute burn-in position as fractions of the free space, derived from the minute alone |
| `domain/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` v3, `exportSchema = true`) and `Migrations` |
| `data/alarms/` | the alarm and ring-state entities, DAOs, mappers and repositories |