feat(settings): a "Week starts on" setting

Settings > Clock gets a "Week starts on" row: Automatic, which follows
the locale (and with it Android 14's regional first-day preference), or
any of the seven days, after Calendula's own. The repeat-day buttons in
the alarm editor and on the bedtime screen, and the alarm list's day
summaries, start on the chosen day. MainActivity reads the preference
once and provides it as LocalWeekStart.

Stored under Calendula's key name, week_start, and carried in the
backup; a file without it, or with a value this version does not know,
restores Automatic.

Closes #30.
This commit is contained in:
makiolaj committed 2026-11-02 19:27:55 +01:00
1 parent 005df7db19
commit 3e74d03ea7
24 files changed
+346 -23

No files matched your search

+3 -2
View File
@@ -26,8 +26,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
Undo brings it back with all its settings, and a snoozed alarm comes back
snoozed.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- Repeat days that start on your locale's first day of the week, or on any
day you pick under "Week starts on" in Settings > Clock, and a "skip next
alarm" switch so you can sleep in once without turning the alarm off.
- An "Upcoming alarm" notification two hours before an alarm rings, with a
Dismiss button, so you can turn off today's alarm when you're already awake.
A repeating alarm skips just that once; a one-time alarm is switched off.
@@ -9,6 +9,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,7 +19,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
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.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
@@ -96,6 +99,10 @@ class MainActivity : ComponentActivity() {
val appearance by settingsPrefs.appearance
.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).
val weekStart by settingsPrefs.weekStart
.collectAsStateWithLifecycle(initialValue = WeekStartPref.Auto)
// The system bars follow the *resolved* theme, not the device night
// flag: with the device dark and ThemeMode.LIGHT stored, deriving the
@@ -109,14 +116,16 @@ class MainActivity : ComponentActivity() {
dynamicColor = appearance.dynamicColor,
pureBlack = pureBlack,
) {
ClockulaShell(
modifier = Modifier.fillMaxSize(),
pureBlack = pureBlack,
openRequest = openRequest,
// Consumed once: a request left standing would re-select the
// tab on every recomposition and fight the user's taps.
onRequestConsumed = { openRequest = null },
)
CompositionLocalProvider(LocalWeekStart provides weekStart) {
ClockulaShell(
modifier = Modifier.fillMaxSize(),
pureBlack = pureBlack,
openRequest = openRequest,
// Consumed once: a request left standing would re-select the
// tab on every recomposition and fight the user's taps.
onRequestConsumed = { openRequest = null },
)
}
// First launch asks for notifications once, and only here:
// it belongs to starting the app, not to any one tab.
RequestNotificationPermissionOnce(settingsPrefs)
@@ -118,6 +118,7 @@ class BackupEngine @Inject constructor(
worldClockSeconds = settings.worldClockSeconds.first(),
screenSaver = settings.screenSaver.first(),
quickTileDuration = settings.quickTileDuration.first(),
weekStart = settings.weekStart.first(),
bedtime = settings.currentBedtime(),
)
}
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
@@ -153,6 +154,7 @@ object BackupCodec {
screenSaverStyle = screenSaver.style.name,
screenSaverNightMode = screenSaver.nightMode,
quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
weekStart = weekStart.storageValue,
)
private fun BedtimeSettings.toDto() = BedtimeDto(
@@ -316,6 +318,7 @@ object BackupCodec {
nightMode = settingsDto.screenSaverNightMode,
),
quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
weekStart = WeekStartPref.parse(settingsDto.weekStart),
bedtime = settingsDto.bedtime?.toBedtime(),
)
@@ -1,5 +1,6 @@
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
@@ -110,6 +111,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,
)
/**
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockLimits
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
@@ -92,6 +93,14 @@ object ClockPrefs {
*/
val automaticLocalClock: Pref<Boolean> = booleanPref("automatic_local_clock", true)
/**
* The first day of the week (#30), under Calendula's key name. Absent means
* [WeekStartPref.Auto], so an upgrade keeps the locale's week. Not a clock
* default either: no alarm overrides it.
*/
val weekStart: Pref<WeekStartPref> =
nullableStringPref("week_start").map(decode = WeekStartPref::parse, encode = { it.storageValue })
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
@@ -4,6 +4,7 @@ import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeEffects
@@ -128,6 +129,7 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
prefs[ClockPrefs.volumeButtons] = backup.defaults.volumeButtons
prefs[ClockPrefs.automaticLocalClock] = backup.defaults.automaticLocalClock
prefs[ClockPrefs.worldClockSeconds] = backup.worldClockSeconds
prefs[ClockPrefs.weekStart] = backup.weekStart
prefs[TimerPrefs.presets] = backup.timerPresets
// Absent from a file written before bedtime existed: keep it.
backup.bedtime?.let { prefs.putBedtime(it) }
@@ -191,6 +193,13 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled)
// --- week start (#30) ---
/** Its own flow, like [worldClockSeconds]: nothing an alarm or timer inherits depends on it. */
val weekStart: Flow<WeekStartPref> = store.flow(ClockPrefs.weekStart)
suspend fun setWeekStart(pref: WeekStartPref) = store.set(ClockPrefs.weekStart, pref)
// --- ring screen over other apps ---
val ringOverOtherApps: Flow<Boolean> = store.flow(ClockPrefs.ringOverOtherApps)
@@ -8,7 +8,7 @@ sealed interface RepeatSummary {
data object Weekdays : RepeatSummary
data object Weekends : RepeatSummary
/** The mask's days, ordered from the locale's first day of week. Never empty. */
/** The mask's days, ordered from the week's first day. Never empty. */
data class SomeDays(val days: List<DayOfWeek>) : RepeatSummary
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
import java.time.temporal.WeekFields
import java.util.Locale
/**
* Which day the week starts on wherever Clockula lays one out: the repeat-day
* buttons and the alarm list's day summaries (#30). [Auto] follows the locale,
* which on Android 14 and up carries the regional first-day preference too;
* [Day] pins any of the seven. After Calendula's own.
*/
sealed interface WeekStartPref {
data object Auto : WeekStartPref
data class Day(val day: DayOfWeek) : WeekStartPref
/** What the preference and the backup store: [AUTO], or a [DayOfWeek] name. */
val storageValue: String
get() = when (this) {
Auto -> AUTO
is Day -> day.name
}
companion object {
const val AUTO = "AUTO"
/** [Auto], then Monday to Sunday: the picker's options. */
val OPTIONS: List<WeekStartPref> = listOf(Auto) + DayOfWeek.entries.map(::Day)
/** Absent, [AUTO] or anything unrecognised reads as [Auto]. */
fun parse(stored: String?): WeekStartPref =
DayOfWeek.entries.firstOrNull { it.name == stored }?.let(::Day) ?: Auto
}
}
/** The concrete first day: the pinned one, or [locale]'s (Monday in DE, Sunday in en-US). */
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
is WeekStartPref.Day -> day
WeekStartPref.Auto -> WeekFields.of(locale).firstDayOfWeek
}
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.bedtime.BedtimeSettings
import de.jeanlucmakiola.clockula.domain.screensaver.ScreenSaverSettings
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
@@ -68,6 +69,8 @@ data class BackupSettings(
val screenSaver: ScreenSaverSettings,
/** What the quick-timer Quick Settings tile starts (#26). */
val quickTileDuration: Duration = QuickTimerWidgetSettings.DEFAULT,
/** The first day of the week (#30). */
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Null for a file written before bedtime existed: restoring it keeps the current schedule. */
val bedtime: BedtimeSettings? = null,
)
@@ -16,6 +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
@@ -38,7 +40,9 @@ 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.floret.components.GroupedListInset
@@ -46,7 +50,6 @@ import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.time.temporal.WeekFields
import java.util.Locale
/**
@@ -268,11 +271,19 @@ internal fun repeatSummaryText(repeatDays: RepeatDays): String {
}
}
/** The locale's own first day of week, re-read with the configuration. */
/**
* 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()
return remember(locale) { WeekFields.of(locale).firstDayOfWeek }
val weekStart = LocalWeekStart.current
return remember(locale, weekStart) { weekStart.resolveFirstDay(locale) }
}
@Composable
@@ -27,7 +27,7 @@ import java.util.Locale
* Seven equal toggle buttons in one row — the Expressive round↔squircle morph
* on check is the shape play a genuinely clock-specific control earns (M5 D28).
*
* The columns start on the locale's own first day of week, and each button
* The columns start on the week's first day ([rememberWeekStart]), and each button
* carries the day's full name as its content description: "M T W T F S S" is
* not something a screen reader can disambiguate.
*/
@@ -4,6 +4,10 @@ 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.res.stringResource
import androidx.compose.ui.unit.dp
@@ -15,9 +19,10 @@ import de.jeanlucmakiola.floret.components.Position
/**
* 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.
* the automatic local clock. Then the day the week starts on, which, like the
* home zone, is a matter of where you live (#30). 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
internal fun ClockSettingsScreen(
@@ -25,6 +30,8 @@ internal fun ClockSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showWeekStart by rememberSaveable { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_clock), onBack = onBack) {
SettingsHint(stringResource(R.string.settings_clock_hint))
Spacer(Modifier.height(8.dp))
@@ -72,6 +79,22 @@ internal fun ClockSettingsScreen(
onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) },
)
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_week_start),
summary = weekStartText(state.weekStart),
position = Position.Alone,
onClick = { showWeekStart = true },
)
ScreenSaverSettingsSection(inApp = true)
}
if (showWeekStart) {
WeekStartPicker(
selected = state.weekStart,
onSelect = viewModel::onWeekStartChanged,
onDismiss = { showWeekStart = false },
)
}
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.clockula.ui.settings
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.floret.prefs.Appearance
/**
@@ -18,6 +19,8 @@ internal sealed interface SettingsUiState {
val worldClockSeconds: Boolean,
/** The stored choice; the screen pairs it with the overlay permission. */
val ringOverOtherApps: Boolean,
/** "Week starts on" (#30); not a clock default either. */
val weekStart: WeekStartPref,
) : SettingsUiState
}
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.Zones
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.selfcheck.CheckStatus
@@ -72,10 +73,10 @@ class SettingsViewModel @Inject internal constructor(
settings.appearance,
settings.defaults,
selfCheckProblemCount,
settings.worldClockSeconds,
combine(settings.worldClockSeconds, settings.weekStart, ::Pair),
settings.ringOverOtherApps,
) { appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps ->
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps)
) { appearance, defaults, problemCount, (worldClockSeconds, weekStart), ringOverOtherApps ->
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps, weekStart)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
/**
@@ -254,6 +255,8 @@ class SettingsViewModel @Inject internal constructor(
fun onWorldClockSecondsChanged(enabled: Boolean) =
viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
fun onWeekStartChanged(pref: WeekStartPref) = viewModelScope.launch { settings.setWeekStart(pref) }
fun onRingOverOtherAppsChanged(enabled: Boolean) =
viewModelScope.launch { settings.setRingOverOtherApps(enabled) }
@@ -0,0 +1,61 @@
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.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())
}
/**
* The week-start chooser (#30), after Calendula's but without its month-grid
* preview: Clockula has no grid to rearrange. "Automatic" carries the day it
* resolves to as its summary, read from the same locale the repeat-day
* buttons resolve it from, so the two never disagree.
*/
@Composable
internal fun WeekStartPicker(
selected: WeekStartPref,
onSelect: (WeekStartPref) -> Unit,
onDismiss: () -> Unit,
) {
val locale = rememberDefaultLocale()
OptionPicker(
title = stringResource(R.string.settings_week_start),
header = { GroupedHint(stringResource(R.string.settings_week_start_hint), verticalPadding = 8.dp) },
options = WeekStartPref.OPTIONS,
selected = selected,
label = { weekStartText(it) },
summary = { option ->
if (option == WeekStartPref.Auto) {
stringResource(
R.string.settings_week_start_auto_summary,
weekStartText(WeekStartPref.Day(option.resolveFirstDay(locale))),
)
} else {
null
}
},
onSelect = onSelect,
onDismiss = onDismiss,
)
}
@Composable
private fun rememberDefaultLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) { Locale.getDefault() }
}
+5 -1
View File
@@ -357,7 +357,7 @@
<string name="settings_timer_defaults_hint">How timers ring. A timer can still pick its own sound in its editor.</string>
<string name="settings_timer_vibrate_off">Off. A timer set to Silent still vibrates</string>
<string name="settings_section_clock">Clock</string>
<string name="settings_clock_subtitle">Home time zone, seconds, local clock and screen saver</string>
<string name="settings_clock_subtitle">Home time zone, seconds, local clock, week start and screen saver</string>
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</string>
<string name="settings_home_zone">Home time zone</string>
<string name="settings_world_clock_seconds">Display time with seconds</string>
@@ -365,6 +365,10 @@
<string name="settings_automatic_local_clock">Automatic local clock</string>
<string name="settings_automatic_local_clock_hint">While you’re somewhere the time differs from home, show the local time under the home clock.</string>
<string name="settings_automatic_local_clock_needs_home">Needs a home time zone. Home follows the device now, so it’s always local time.</string>
<string name="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_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>
<string name="screen_saver_style_digital">Digital</string>
@@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.testing.BERLIN
@@ -19,6 +20,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -44,6 +46,7 @@ class BackupEngineTest {
source.settings.setSnoozeMinutes(7)
source.settings.setHomeZoneId("America/New_York")
source.settings.setWorldClockSeconds(true)
source.settings.setWeekStart(WeekStartPref.Day(DayOfWeek.MONDAY))
val exported = source.backupEngine.export()
@@ -70,6 +73,7 @@ class BackupEngineTest {
assertThat(target.settings.currentDefaults().snoozeMinutes).isEqualTo(7)
assertThat(target.settings.currentDefaults().homeZoneId).isEqualTo("America/New_York")
assertThat(target.settings.worldClockSeconds.first()).isTrue()
assertThat(target.settings.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY))
}
@Test
@@ -6,6 +6,7 @@ import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.PauseRange
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.backup.BackupAlarm
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
@@ -21,6 +22,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.domain.widget.QuickTimerWidgetSettings
import de.jeanlucmakiola.floret.prefs.ThemeMode
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalDate
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
@@ -81,6 +83,7 @@ class BackupCodecTest {
worldClockSeconds = true,
quickTileDuration = 3.minutes,
screenSaver = ScreenSaverSettings(style = ScreenSaverStyle.ANALOG, nightMode = true),
weekStart = WeekStartPref.Day(DayOfWeek.SATURDAY),
bedtime = BedtimeSettings(
enabled = true,
bedtime = TimeOfDay(23, 15),
@@ -194,6 +197,20 @@ class BackupCodecTest {
assertThat(decoded.defaults().timerVibrate).isFalse()
}
@Test
fun `a file from before the week start setting follows the locale`() {
val decoded = BackupCodec.decode(settingsOnly(""""default_vibrate": true"""))
assertThat((decoded as BackupResult.Success).content.settings.weekStart).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `an unknown week start follows the locale rather than rejecting the file`() {
val decoded = BackupCodec.decode(settingsOnly(""""week_start": "FUNDAY""""))
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 } }"""
@@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
@@ -32,6 +33,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.minutes
/**
@@ -108,6 +110,7 @@ class SettingsPrefsTest {
worldClockSeconds = false,
quickTileDuration = 2.minutes,
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
weekStart = WeekStartPref.Day(DayOfWeek.MONDAY),
),
)
@@ -125,6 +128,49 @@ class SettingsPrefsTest {
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
assertThat(prefs.quickTileDuration.first()).isEqualTo(2.minutes)
assertThat(prefs.screenSaver.first()).isEqualTo(ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true))
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY))
}
// --- #30: week start ---
@Test
fun `a fresh install follows the locale's week`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `a pinned week start round-trips, and so does going back to automatic`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
prefs.setWeekStart(WeekStartPref.Auto)
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `the week start is written under Calendula's key name`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.MONDAY))
assertThat(dataStore.data.first()[stringPreferencesKey("week_start")]).isEqualTo("MONDAY")
}
@Test
fun `a corrupted week start falls back to the locale's`(@TempDir tempDir: Path) = runTest {
val dataStore = newDataStore(tempDir)
val prefs = SettingsPrefs(PrefStore(dataStore))
dataStore.updateData { stored ->
stored.toMutablePreferences().apply { this[stringPreferencesKey("week_start")] = "FUNDAY" }
}
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
}
@Test
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.util.Locale
/** "Week starts on" (#30): the locale's day unless one is pinned, and a stored value never throws. */
class WeekStartPrefTest {
@Test
fun `automatic follows the locale`() {
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `a pinned day wins over the locale`() {
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
.isEqualTo(DayOfWeek.SATURDAY)
}
@Test
fun `the regional first-day extension is honoured`() {
val germanSundays = Locale.forLanguageTag("de-DE-u-fw-sun")
assertThat(WeekStartPref.Auto.resolveFirstDay(germanSundays)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `every option round-trips through its stored value`() {
WeekStartPref.OPTIONS.forEach { option ->
assertThat(WeekStartPref.parse(option.storageValue)).isEqualTo(option)
}
}
@Test
fun `absent or unknown reads as automatic`() {
assertThat(WeekStartPref.parse(null)).isEqualTo(WeekStartPref.Auto)
assertThat(WeekStartPref.parse("FUNDAY")).isEqualTo(WeekStartPref.Auto)
assertThat(WeekStartPref.parse("monday")).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `the options are automatic then monday to sunday`() {
assertThat(WeekStartPref.OPTIONS.first()).isEqualTo(WeekStartPref.Auto)
assertThat(WeekStartPref.OPTIONS.drop(1))
.containsExactlyElementsIn(DayOfWeek.entries.map(WeekStartPref::Day))
.inOrder()
}
}
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.RepeatDays
import de.jeanlucmakiola.clockula.domain.WeekStartPref
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
@@ -52,6 +53,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.DayOfWeek
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -235,6 +237,19 @@ class SettingsViewModelTest {
assertThat((viewModel.state.value as SettingsUiState.Loaded).worldClockSeconds).isTrue()
}
@Test
fun `the week start writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).weekStart).isEqualTo(WeekStartPref.Auto)
viewModel.onWeekStartChanged(WeekStartPref.Day(DayOfWeek.SATURDAY))
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).weekStart)
.isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
}
@Test
fun `choosing a default alarm ringtone writes through and closes the picker`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
+1
View File
@@ -618,6 +618,7 @@ Six slices:
|---|---|---|
| Appearance (M1) | `theme_mode`, `dynamic_color` | `AppearancePrefs` in `core-prefs`, the family's shared key names |
| Clock defaults (M2) | `default_snooze_minutes`, `default_snooze_limit`, `default_vibrate`, `default_volume_ramp_seconds`, `default_alarm_ringtone_uri`, `default_timer_ringtone_uri`, `default_timer_vibrate`, `default_timer_volume_ramp_seconds` (#22), `default_dismiss_challenge`, `home_zone_id`, `volume_buttons_while_ringing` (#18; app-wide, no per-alarm override), `automatic_local_clock` (#24) | `ClockPrefs`, surfaced as `SettingsPrefs.defaults: Flow<ClockDefaults>` |
| Week start (#30) | `week_start` | `ClockPrefs`: `AUTO` (absent reads the same) follows the locale, a `DayOfWeek` name pins that day; surfaced as `SettingsPrefs.weekStart`, provided to every screen by `MainActivity` as `LocalWeekStart`, and carried in the backup |
| World clock display (#23) | `world_clock_show_seconds` | `ClockPrefs`, surfaced as its own `SettingsPrefs.worldClockSeconds`, not a clock default, so flipping it re-emits nothing alarms and timers watch |
| Stopwatch run record (M2) | `stopwatch_state`, `stopwatch_started_elapsed_millis`, `stopwatch_accumulated_millis`, `stopwatch_last_lap_cumulative_millis` (M7 is the first caller of the last, for the in-progress lap's split) | `StopwatchPrefs`, behind `StopwatchStateStore` |
| System facts (M3) | `last_boot_id` | `SystemPrefs`, behind `BootStateStore`, the persisted half of the reboot gate |
+6 -1
View File
@@ -179,7 +179,8 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
},
"screen_saver_style": "DIGITAL",
"screen_saver_night_mode": false,
"quick_tile_duration_millis": 300000
"quick_tile_duration_millis": 300000,
"week_start": "AUTO"
}
```
@@ -216,6 +217,10 @@ default. It travels with the settings but is not a clock default: nothing an
alarm or timer inherits depends on it. A file without `automatic_local_clock`
(one written before it existed) restores it as `true`, its default.
`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`.
Every numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.