fix: review findings on week start and settings motion

- The last week start is saved with the activity, so a rotation no
  longer draws one frame in the locale's order before the stored day.
- A backup with "week_start": null restores Automatic instead of
  rejecting the whole file.
- Under reduced motion a settings sub-page fades in, matching its exit.
- LocalWeekStart, rememberWeekStart and one shared rememberLocale move
  to ui/common; the picker, the day buttons and the summaries use them.
- The "Week starts on" row shows the day Automatic resolves to.
This commit is contained in:
makiolaj committed 2026-11-02 19:40:06 +01:00
1 parent fe17bcc8e8
commit 0148031907
11 files changed
+79 -56

No files matched your search

@@ -13,6 +13,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
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.lifecycle.compose.collectAsStateWithLifecycle
@@ -21,7 +22,7 @@ import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.interop.InteropIntents
import de.jeanlucmakiola.clockula.ui.alarms.LocalWeekStart
import de.jeanlucmakiola.clockula.ui.common.LocalWeekStart
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
@@ -100,9 +101,13 @@ class MainActivity : ComponentActivity() {
.collectAsStateWithLifecycle(initialValue = Appearance())
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
// Read here once rather than by each screen that lays out a week:
// the alarm list, the alarm editor and bedtime all do (#30).
// the alarm list, the alarm editor and bedtime all do (#30). The
// last value is saved with the activity, so a rotation or a theme
// change doesn't draw one frame in the locale's order first.
var lastWeekStart by rememberSaveable { mutableStateOf(WeekStartPref.AUTO) }
val weekStart by settingsPrefs.weekStart
.collectAsStateWithLifecycle(initialValue = WeekStartPref.Auto)
.collectAsStateWithLifecycle(initialValue = WeekStartPref.parse(lastWeekStart))
LaunchedEffect(weekStart) { lastWeekStart = weekStart.storageValue }
// The system bars follow the *resolved* theme, not the device night
// flag: with the device dark and ThemeMode.LIGHT stored, deriving the
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.clockula.data.backup
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import kotlinx.serialization.SerialName
@@ -113,8 +112,8 @@ data class SettingsDto(
@SerialName("screen_saver_night_mode") val screenSaverNightMode: Boolean = false,
/** Absent (a file from before #26) or out of range keeps the tile's default. */
@SerialName("quick_tile_duration_millis") val quickTileDurationMillis: Long? = null,
/** Absent (a file from before #30) or unrecognised follows the locale, the default. */
@SerialName("week_start") val weekStart: String = WeekStartPref.AUTO,
/** Absent (a file from before #30), null or unrecognised follows the locale, the default. */
@SerialName("week_start") val weekStart: String? = null,
)
/**
@@ -16,12 +16,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -40,17 +36,15 @@ import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
import de.jeanlucmakiola.clockula.domain.RepeatSummary
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
import de.jeanlucmakiola.clockula.domain.resolveFirstDay
import de.jeanlucmakiola.clockula.ui.common.SwipeToDelete
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.clockula.ui.common.rememberLocale
import de.jeanlucmakiola.clockula.ui.common.rememberWeekStart
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale
/**
* A standalone alarm card. The time and switch share the first line, leaving
@@ -270,26 +264,3 @@ internal fun repeatSummaryText(repeatDays: RepeatDays): String {
}
}
}
/**
* The stored "Week starts on" choice (#30), provided once by `MainActivity` for
* every screen that lays out a week. Auto outside it, as in a preview.
*/
internal val LocalWeekStart: ProvidableCompositionLocal<WeekStartPref> =
compositionLocalOf { WeekStartPref.Auto }
/** The chosen first day of week, or the locale's own, re-read with the configuration. */
@Composable
internal fun rememberWeekStart(): DayOfWeek {
val locale = rememberLocale()
val weekStart = LocalWeekStart.current
return remember(locale, weekStart) { weekStart.resolveFirstDay(locale) }
}
@Composable
private fun rememberLocale(): Locale {
// Keyed on the configuration, so a language change re-reads both the
// locale's day names and where its week begins.
val configuration = LocalConfiguration.current
return remember(configuration) { Locale.getDefault() }
}
@@ -8,20 +8,19 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.RepeatDaysOrder
import de.jeanlucmakiola.clockula.ui.common.rememberLocale
import de.jeanlucmakiola.clockula.ui.common.rememberWeekStart
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale
/**
* Seven equal toggle buttons in one row — the Expressive round↔squircle morph
@@ -39,8 +38,7 @@ internal fun RepeatDaySelector(
modifier: Modifier = Modifier,
position: Position = Position.Alone,
) {
val configuration = LocalConfiguration.current
val locale = remember(configuration) { Locale.getDefault() }
val locale = rememberLocale()
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
GroupedSurface(
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.clockula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.resolveFirstDay
import java.time.DayOfWeek
import java.util.Locale
/**
* The stored "Week starts on" choice (#30), provided once by `MainActivity` for
* every screen that lays out a week. Auto outside it, as in a preview.
*/
internal val LocalWeekStart: ProvidableCompositionLocal<WeekStartPref> =
compositionLocalOf { WeekStartPref.Auto }
/** The chosen first day of week, or the locale's own, re-read with the configuration. */
@Composable
internal fun rememberWeekStart(): DayOfWeek {
val locale = rememberLocale()
val weekStart = LocalWeekStart.current
return remember(locale, weekStart) { weekStart.resolveFirstDay(locale) }
}
/**
* The locale day names and the week's first day are read in. Keyed on the
* configuration, so a language change re-reads both; shared, so the repeat-day
* buttons, the alarm summaries and the week-start picker never disagree.
*/
@Composable
internal fun rememberLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) { Locale.getDefault() }
}
@@ -101,7 +101,7 @@ internal fun ClockSettingsScreen(
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartText(state.weekStart),
summary = weekStartSummary(state.weekStart),
position = Position.Alone,
onClick = { showWeekStart = true },
)
@@ -63,6 +63,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, Alarms, Timers, Clock }
@@ -182,9 +183,11 @@ fun SettingsScreen(
@Composable
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
// Reduced motion fades it in, as predictiveBackExit already fades it out.
val enter = if (rememberReduceMotion()) fadeIn() else slideInHorizontally(slideSpec) { it } + fadeIn()
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
enter = enter,
exit = predictiveBackExit(),
) { content() }
}
@@ -1,23 +1,31 @@
package de.jeanlucmakiola.clockula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.resolveFirstDay
import de.jeanlucmakiola.clockula.ui.common.rememberLocale
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.OptionPicker
import java.time.format.TextStyle
import java.util.Locale
/** A pinned day is the locale's own weekday name, so no per-day string is needed. */
@Composable
internal fun weekStartText(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
is WeekStartPref.Day -> pref.day.getDisplayName(TextStyle.FULL, rememberDefaultLocale())
is WeekStartPref.Day -> pref.day.getDisplayName(TextStyle.FULL, rememberLocale())
}
/** The row's summary: a pinned day, or "Automatic (Monday)" with the day it resolves to. */
@Composable
internal fun weekStartSummary(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(
R.string.settings_week_start_auto_row,
weekStartText(WeekStartPref.Day(pref.resolveFirstDay(rememberLocale()))),
)
is WeekStartPref.Day -> weekStartText(pref)
}
/**
@@ -32,7 +40,7 @@ internal fun WeekStartPicker(
onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit,
) {
val locale = rememberDefaultLocale()
val locale = rememberLocale()
OptionPicker(
title = stringResource(R.string.settings_week_start),
header = { GroupedHint(stringResource(R.string.settings_week_start_hint), verticalPadding = 8.dp) },
@@ -53,9 +61,3 @@ internal fun WeekStartPicker(
onDismiss = onDismiss,
)
}
@Composable
private fun rememberDefaultLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) { Locale.getDefault() }
}
+1
View File
@@ -370,6 +370,7 @@
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_start_auto_summary">Currently %1$s</string>
<string name="settings_week_start_auto_row">Automatic (%1$s)</string>
<string name="settings_week_start_hint">The day every week begins on, in alarms and bedtime.</string>
<string name="screen_saver_title">Screen saver</string>
<string name="screen_saver_style">Clock style</string>
@@ -212,6 +212,13 @@ class BackupCodecTest {
assertThat((decoded as BackupResult.Success).content.settings.weekStart).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `a null week start follows the locale rather than rejecting the file`() {
val decoded = BackupCodec.decode(settingsOnly(""""week_start": null"""))
assertThat((decoded as BackupResult.Success).content.settings.weekStart).isEqualTo(WeekStartPref.Auto)
}
private fun settingsOnly(settings: String): String =
"""{ "format": "clockula-backup", "version": 1, "exportedAt": 0, "settings": { $settings } }"""
+1 -1
View File
@@ -221,7 +221,7 @@ alarm or timer inherits depends on it. The same goes for
`week_start` is the first day of the week (#30): `AUTO`, which follows the
locale, or a day name from `MONDAY` to `SUNDAY`. A file without it, or with a
value this version does not know, restores `AUTO`.
value this version does not know or `null`, restores `AUTO`.
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.