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:
commit
381f2b8f69
25 files changed
+347
-24
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(
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
Submodule floret-kit updated: e73e5d8b76...8593181618.
Reference in new issue
Block a user