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:
1 parent
005df7db19
commit
3e74d03ea7
24 files changed
+346
-23
No files matched your search
+3
-2
@@ -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() }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user