Merge branch 'feat/30-week-start-impl' into release/v1.0.0

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
This commit is contained in:
makiolaj committed 2026-11-02 19:29:55 +01:00
commit 381f2b8f69
25 files changed
+347 -24

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(
@@ -26,6 +31,8 @@ internal fun ClockSettingsScreen(
onBack: () -> Unit,
visible: Boolean,
) {
var showWeekStart by rememberSaveable { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_clock),
onBack = onBack,
@@ -78,6 +85,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.