From 3e74d03ea794d3266cabda29bad8473e36da6a43 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 2 Nov 2026 19:27:55 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 5 +- .../jeanlucmakiola/clockula/MainActivity.kt | 25 +++++--- .../clockula/backup/BackupEngine.kt | 1 + .../clockula/data/backup/BackupCodec.kt | 3 + .../clockula/data/backup/BackupDto.kt | 3 + .../clockula/data/prefs/ClockPrefs.kt | 9 +++ .../clockula/data/prefs/SettingsPrefs.kt | 9 +++ .../clockula/domain/RepeatSummary.kt | 2 +- .../clockula/domain/WeekStartPref.kt | 40 ++++++++++++ .../clockula/domain/backup/BackupContent.kt | 3 + .../clockula/ui/alarms/AlarmRow.kt | 17 +++++- .../clockula/ui/alarms/RepeatDaySelector.kt | 2 +- .../ui/settings/ClockSettingsScreen.kt | 29 ++++++++- .../clockula/ui/settings/SettingsUiState.kt | 3 + .../clockula/ui/settings/SettingsViewModel.kt | 9 ++- .../clockula/ui/settings/WeekStartPicker.kt | 61 +++++++++++++++++++ app/src/main/res/values/strings.xml | 6 +- .../clockula/backup/BackupEngineTest.kt | 4 ++ .../clockula/data/backup/BackupCodecTest.kt | 17 ++++++ .../clockula/data/prefs/SettingsPrefsTest.kt | 46 ++++++++++++++ .../clockula/domain/WeekStartPrefTest.kt | 52 ++++++++++++++++ .../ui/settings/SettingsViewModelTest.kt | 15 +++++ docs/ARCHITECTURE.md | 1 + docs/BACKUP.md | 7 ++- 24 files changed, 346 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/WeekStartPref.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/WeekStartPicker.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/WeekStartPrefTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 81173ad..61ffcad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index acba4ae..ac46a91 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt index 18aba23..2569161 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/backup/BackupEngine.kt @@ -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(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt index f4231de..cbc0e8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupCodec.kt @@ -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(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt index 1e6a3e4..215f58f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/backup/BackupDto.kt @@ -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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index 452ac96..b5eacab 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -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 = 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 = + nullableStringPref("week_start").map(decode = WeekStartPref::parse, encode = { it.storageValue }) + private fun Pref.clampedTo(range: IntRange): Pref = map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) }) 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 755c879..4ccfb62 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 @@ -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 = store.flow(ClockPrefs.weekStart) + + suspend fun setWeekStart(pref: WeekStartPref) = store.set(ClockPrefs.weekStart, pref) + // --- ring screen over other apps --- val ringOverOtherApps: Flow = store.flow(ClockPrefs.ringOverOtherApps) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt index 8aea681..23e32da 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/RepeatSummary.kt @@ -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) : RepeatSummary } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WeekStartPref.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WeekStartPref.kt new file mode 100644 index 0000000..527486b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WeekStartPref.kt @@ -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 = 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 +} 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 37a8f73..e94f4e0 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 @@ -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, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt index 0616540..8ad28bc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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 = + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt index 6a1916e..d179b33 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt @@ -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. */ 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 d23bb78..d0ac7b9 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 @@ -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 }, + ) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 7cb1cbc..0f14ee3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 6d53dde..28338a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/WeekStartPicker.kt new file mode 100644 index 0000000..47b9eaa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/WeekStartPicker.kt @@ -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() } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3d8960..4e37589 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,7 +357,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, local clock and screen saver + Home time zone, seconds, local clock, week start and screen saver World clocks show how far each city is from your home time zone. Home time zone Display time with seconds @@ -365,6 +365,10 @@ 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. + Week starts on + Automatic + Currently %1$s + The day every week begins on, in alarms and bedtime. Screen saver Clock style Digital diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt index 8a88a8f..5b9fb93 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt index 9e90e31..f44fae8 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/backup/BackupCodecTest.kt @@ -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 } }""" diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt index 872a1c4..1ff5492 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefsTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/WeekStartPrefTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/WeekStartPrefTest.kt new file mode 100644 index 0000000..b5bc6de --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/WeekStartPrefTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt index 8e951ce..83ea17f 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt @@ -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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d55a85..5e584a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | +| 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 | diff --git a/docs/BACKUP.md b/docs/BACKUP.md index 6268ede..7c9bb92 100644 --- a/docs/BACKUP.md +++ b/docs/BACKUP.md @@ -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.