Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81a3641ce | ||
|
|
4a29346351 | ||
|
|
0148031907 | ||
|
|
fe17bcc8e8 | ||
|
|
381f2b8f69 | ||
|
|
8c5c664cd9 | ||
|
|
f3789fc164 | ||
|
|
7d65b2b206 | ||
|
|
3e74d03ea7 | ||
|
|
d749bc0b7b | ||
|
|
5d7876684d | ||
|
|
005df7db19 | ||
|
|
0a92dce8a9 | ||
|
|
cd705961ca | ||
|
|
a390296dc2 | ||
|
|
c4fd0476e4 | ||
|
|
b03b23da30 |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
branch = feat/clockula
|
||||
branch = main
|
||||
+7
-3
@@ -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.
|
||||
@@ -72,7 +73,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
the offset from your time and whether it's a different day there. Reorder
|
||||
them by dragging or with a screen reader, and set a home time zone or follow
|
||||
the device. "Display time with seconds" in Settings > Clock adds seconds to
|
||||
every time on the tab and a second hand to the face.
|
||||
every time on the tab and a second hand to the face, and "Compact home
|
||||
clock" swaps the face for a row like the other cities'.
|
||||
- An automatic local clock while travelling. When you've set a home time zone
|
||||
and your device is somewhere the time differs, the World clock tab adds a
|
||||
"Local time" row under the home face. It goes away again once you're back
|
||||
@@ -87,6 +89,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||
with Clockula. A voice-set alarm cleans up after itself once dismissed.
|
||||
- Settings: theme, dynamic colour, language, and the defaults every new alarm
|
||||
and timer inherits.
|
||||
- A Black theme next to Light and Dark: dark with pure black backgrounds,
|
||||
navigation bar included, for AMOLED screens.
|
||||
- Choose how long a ringing alarm or a finished timer keeps going before it
|
||||
stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for
|
||||
alarms and timers. Ten minutes stays the default, and Never still stops
|
||||
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// versionCode is derived from versionName by scripts/version_info.sh
|
||||
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
|
||||
// committed one doesn't match. See docs/RELEASING.md.
|
||||
versionCode = 1000005
|
||||
versionName = "1.0.0-beta.5"
|
||||
versionCode = 1000008
|
||||
versionName = "1.0.0-beta.8"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@ 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
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.common.LocalWeekStart
|
||||
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
||||
@@ -30,6 +34,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -95,24 +100,37 @@ 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). The
|
||||
// last value is saved with the activity, so a rotation or a theme
|
||||
// change doesn't draw one frame in the locale's order first.
|
||||
var lastWeekStart by rememberSaveable { mutableStateOf(WeekStartPref.AUTO) }
|
||||
val weekStart by settingsPrefs.weekStart
|
||||
.collectAsStateWithLifecycle(initialValue = WeekStartPref.parse(lastWeekStart))
|
||||
LaunchedEffect(weekStart) { lastWeekStart = weekStart.storageValue }
|
||||
|
||||
// The system bars follow the *resolved* theme, not the device night
|
||||
// flag: with the device dark and ThemeMode.LIGHT stored, deriving the
|
||||
// icon colours from the system would paint white icons onto a light
|
||||
// background. Re-applied whenever the stored preference changes.
|
||||
LaunchedEffect(darkTheme) { applyEdgeToEdge(darkTheme) }
|
||||
val pureBlack = darkTheme && appearance.themeMode.isPureBlack
|
||||
LaunchedEffect(darkTheme, pureBlack) { applyEdgeToEdge(darkTheme, pureBlack) }
|
||||
|
||||
ClockulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
pureBlack = pureBlack,
|
||||
) {
|
||||
ClockulaShell(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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)
|
||||
@@ -134,10 +152,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEdgeToEdge(darkTheme: Boolean) {
|
||||
private fun applyEdgeToEdge(darkTheme: Boolean, pureBlack: Boolean) {
|
||||
val darkScrim = if (pureBlack) NAV_BAR_BLACK_SCRIM else NAV_BAR_DARK_SCRIM
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
||||
navigationBarStyle = SystemBarStyle.auto(NAV_BAR_LIGHT_SCRIM, NAV_BAR_DARK_SCRIM) { darkTheme },
|
||||
navigationBarStyle = SystemBarStyle.auto(NAV_BAR_LIGHT_SCRIM, darkScrim) { darkTheme },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,3 +204,6 @@ class MainActivity : ComponentActivity() {
|
||||
/** The scrims androidx uses for a three-button navigation bar, matched exactly. */
|
||||
private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
|
||||
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
||||
|
||||
/** The Black theme's: the dark one's alpha over black, so the bar reads black (#28). */
|
||||
private val NAV_BAR_BLACK_SCRIM = Color.argb(0x80, 0x00, 0x00, 0x00)
|
||||
@@ -116,8 +116,10 @@ class BackupEngine @Inject constructor(
|
||||
defaults = settings.currentDefaults(),
|
||||
timerPresets = settings.timerPresets.first(),
|
||||
worldClockSeconds = settings.worldClockSeconds.first(),
|
||||
compactHomeClock = settings.compactHomeClock.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
|
||||
@@ -148,11 +149,13 @@ object BackupCodec {
|
||||
volumeButtons = defaults.volumeButtons.name,
|
||||
automaticLocalClock = defaults.automaticLocalClock,
|
||||
worldClockSeconds = worldClockSeconds,
|
||||
compactHomeClock = compactHomeClock,
|
||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
||||
bedtime = bedtime?.toDto(),
|
||||
screenSaverStyle = screenSaver.style.name,
|
||||
screenSaverNightMode = screenSaver.nightMode,
|
||||
quickTileDurationMillis = quickTileDuration.inWholeMilliseconds,
|
||||
weekStart = weekStart.storageValue,
|
||||
)
|
||||
|
||||
private fun BedtimeSettings.toDto() = BedtimeDto(
|
||||
@@ -311,11 +314,13 @@ object BackupCodec {
|
||||
),
|
||||
timerPresets = settingsDto.timerPresets?.map { it.milliseconds } ?: TimerPresets.BUILT_IN,
|
||||
worldClockSeconds = settingsDto.worldClockSeconds,
|
||||
compactHomeClock = settingsDto.compactHomeClock,
|
||||
screenSaver = ScreenSaverSettings(
|
||||
style = settingsDto.screenSaverStyle.toEnum(ScreenSaverStyle.DIGITAL),
|
||||
nightMode = settingsDto.screenSaverNightMode,
|
||||
),
|
||||
quickTileDuration = parseQuickTimerDuration(settingsDto.quickTileDurationMillis),
|
||||
weekStart = WeekStartPref.parse(settingsDto.weekStart),
|
||||
bedtime = settingsDto.bedtime?.toBedtime(),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ data class SettingsDto(
|
||||
@SerialName("automatic_local_clock") val automaticLocalClock: Boolean = true,
|
||||
/** Absent in files from before it existed, which keeps the default: off. */
|
||||
@SerialName("world_clock_show_seconds") val worldClockSeconds: Boolean = false,
|
||||
/** Absent in files from before #29, which keeps the default: off. */
|
||||
@SerialName("world_clock_compact_home") val compactHomeClock: Boolean = false,
|
||||
/**
|
||||
* Absent is not empty: an empty list is a user who removed every preset,
|
||||
* while a file without the field (an older or hand-written one) keeps the
|
||||
@@ -110,6 +112,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), null or unrecognised follows the locale, the default. */
|
||||
@SerialName("week_start") val weekStart: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
@@ -74,6 +75,13 @@ object ClockPrefs {
|
||||
*/
|
||||
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false)
|
||||
|
||||
/**
|
||||
* The world clock tab's home as a row like the cities' rather than the
|
||||
* analog face (#29). Off by default. Not part of `ClockDefaults`, for the
|
||||
* same reason as [worldClockSeconds].
|
||||
*/
|
||||
val compactHomeClock: Pref<Boolean> = booleanPref("world_clock_compact_home", false)
|
||||
|
||||
/**
|
||||
* Whether a ringing alarm or timer opens its screen over another app
|
||||
* rather than as a heads-up. Off by default, and only in effect while the
|
||||
@@ -92,6 +100,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,8 @@ 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[ClockPrefs.compactHomeClock] = backup.compactHomeClock
|
||||
prefs[TimerPrefs.presets] = backup.timerPresets
|
||||
// Absent from a file written before bedtime existed: keep it.
|
||||
backup.bedtime?.let { prefs.putBedtime(it) }
|
||||
@@ -191,6 +194,20 @@ 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)
|
||||
|
||||
// --- compact home clock (#29) ---
|
||||
|
||||
/** Its own flow, as [worldClockSeconds] is (#29). */
|
||||
val compactHomeClock: Flow<Boolean> = store.flow(ClockPrefs.compactHomeClock)
|
||||
|
||||
suspend fun setCompactHomeClock(enabled: Boolean) = store.set(ClockPrefs.compactHomeClock, enabled)
|
||||
|
||||
// --- 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
|
||||
@@ -65,9 +66,13 @@ data class BackupSettings(
|
||||
val timerPresets: List<Duration>,
|
||||
/** The world clock's "Display time with seconds" (#23). */
|
||||
val worldClockSeconds: Boolean,
|
||||
/** The world clock's "Compact home clock" (#29). */
|
||||
val compactHomeClock: Boolean = false,
|
||||
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,10 +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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
@@ -41,13 +39,12 @@ import de.jeanlucmakiola.clockula.domain.RepeatSummary
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.ui.common.SwipeToDelete
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberLocale
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberWeekStart
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
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
|
||||
|
||||
/**
|
||||
* A standalone alarm card. The time and switch share the first line, leaving
|
||||
@@ -267,18 +264,3 @@ internal fun repeatSummaryText(repeatDays: RepeatDays): String {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The locale's own first day of week, re-read with the configuration. */
|
||||
@Composable
|
||||
internal fun rememberWeekStart(): DayOfWeek {
|
||||
val locale = rememberLocale()
|
||||
return remember(locale) { WeekFields.of(locale).firstDayOfWeek }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberLocale(): Locale {
|
||||
// Keyed on the configuration, so a language change re-reads both the
|
||||
// locale's day names and where its week begins.
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) { Locale.getDefault() }
|
||||
}
|
||||
@@ -8,26 +8,25 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDaysOrder
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberLocale
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberWeekStart
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
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.
|
||||
*/
|
||||
@@ -39,8 +38,7 @@ internal fun RepeatDaySelector(
|
||||
modifier: Modifier = Modifier,
|
||||
position: Position = Position.Alone,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val locale = remember(configuration) { Locale.getDefault() }
|
||||
val locale = rememberLocale()
|
||||
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
|
||||
|
||||
GroupedSurface(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.clockula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import de.jeanlucmakiola.clockula.domain.WeekStartPref
|
||||
import de.jeanlucmakiola.clockula.domain.resolveFirstDay
|
||||
import java.time.DayOfWeek
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 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()
|
||||
val weekStart = LocalWeekStart.current
|
||||
return remember(locale, weekStart) { weekStart.resolveFirstDay(locale) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale day names and the week's first day are read in. Keyed on the
|
||||
* configuration, so a language change re-reads both; shared, so the repeat-day
|
||||
* buttons, the alarm summaries and the week-start picker never disagree.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) { Locale.getDefault() }
|
||||
}
|
||||
+6
-1
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,11 @@ class ScreenSaverSettingsActivity : ComponentActivity() {
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
||||
)
|
||||
}
|
||||
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
|
||||
ClockulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
pureBlack = appearance.themeMode.isPureBlack,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.screen_saver_title), onBack = ::finish) {
|
||||
ScreenSaverSettingsSection(inApp = false)
|
||||
}
|
||||
|
||||
@@ -49,11 +49,17 @@ internal fun AlarmDefaultsScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val defaults = state.defaults
|
||||
var openPicker by rememberSaveable { mutableStateOf<OpenAlarmPicker?>(null) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_alarms), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_alarms),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_alarm_defaults_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
|
||||
@@ -53,13 +53,19 @@ internal fun AppearanceScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
var showTheme by rememberSaveable { mutableStateOf(false) }
|
||||
var showAppIcon by rememberSaveable { mutableStateOf(false) }
|
||||
val launcherIcon by viewModel.launcherIcon.collectAsStateWithLifecycle()
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = themeModeLabel(state.appearance.themeMode),
|
||||
@@ -140,6 +146,9 @@ internal fun AppearanceScreen(
|
||||
options = ThemeMode.entries.toList(),
|
||||
selected = state.appearance.themeMode,
|
||||
label = { themeModeLabel(it) },
|
||||
summary = { mode ->
|
||||
if (mode == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null
|
||||
},
|
||||
onSelect = viewModel::onThemeModeSelected,
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
@@ -152,6 +161,7 @@ private fun themeModeLabel(mode: ThemeMode): String = stringResource(
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||
ThemeMode.BLACK -> R.string.settings_theme_black
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -14,18 +18,28 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
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 clock tab's settings: the home zone, whether home is a face or a row,
|
||||
* whether the tab shows seconds and 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(
|
||||
state: SettingsUiState.Loaded,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_clock), onBack = onBack) {
|
||||
var showWeekStart by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_clock),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_clock_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -35,6 +49,18 @@ internal fun ClockSettingsScreen(
|
||||
position = Position.Top,
|
||||
onClick = viewModel::onOpenHomeZonePicker,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_compact_home_clock),
|
||||
summary = stringResource(R.string.settings_compact_home_clock_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.compactHomeClock,
|
||||
onCheckedChange = viewModel::onCompactHomeClockChanged,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.onCompactHomeClockChanged(!state.compactHomeClock) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_world_clock_seconds),
|
||||
summary = stringResource(R.string.settings_world_clock_seconds_hint),
|
||||
@@ -72,6 +98,22 @@ internal fun ClockSettingsScreen(
|
||||
onClick = { viewModel.onAutomaticLocalClockChanged(!automaticLocalClock) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartSummary(state.weekStart),
|
||||
position = Position.Alone,
|
||||
onClick = { showWeekStart = true },
|
||||
)
|
||||
|
||||
ScreenSaverSettingsSection(inApp = true)
|
||||
}
|
||||
|
||||
if (showWeekStart) {
|
||||
WeekStartPicker(
|
||||
selected = state.weekStart,
|
||||
onSelect = viewModel::onWeekStartChanged,
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package de.jeanlucmakiola.clockula.ui.settings
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -28,6 +25,7 @@ import androidx.compose.material.icons.filled.PrivacyTip
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -38,6 +36,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -63,6 +62,8 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, Alarms, Timers, Clock }
|
||||
@@ -114,10 +115,6 @@ fun SettingsScreen(
|
||||
|
||||
val loaded = state as? SettingsUiState.Loaded
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// rather than popping the whole Settings destination.
|
||||
BackHandler(enabled = section != null) { section = null }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -133,17 +130,21 @@ fun SettingsScreen(
|
||||
)
|
||||
|
||||
if (loaded != null) {
|
||||
SlideInSection(visible = section == SettingsSection.Appearance) {
|
||||
AppearanceScreen(state = loaded, viewModel = viewModel, onBack = { section = null })
|
||||
val appearanceOpen = section == SettingsSection.Appearance
|
||||
SlideInSection(visible = appearanceOpen) {
|
||||
AppearanceScreen(state = loaded, viewModel = viewModel, onBack = { section = null }, visible = appearanceOpen)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Alarms) {
|
||||
AlarmDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null })
|
||||
val alarmsOpen = section == SettingsSection.Alarms
|
||||
SlideInSection(visible = alarmsOpen) {
|
||||
AlarmDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }, visible = alarmsOpen)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Timers) {
|
||||
TimerDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null })
|
||||
val timersOpen = section == SettingsSection.Timers
|
||||
SlideInSection(visible = timersOpen) {
|
||||
TimerDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }, visible = timersOpen)
|
||||
}
|
||||
SlideInSection(visible = section == SettingsSection.Clock) {
|
||||
ClockSettingsScreen(state = loaded, viewModel = viewModel, onBack = { section = null })
|
||||
val clockOpen = section == SettingsSection.Clock
|
||||
SlideInSection(visible = clockOpen) {
|
||||
ClockSettingsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }, visible = clockOpen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,12 +173,22 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-screen layered over the hub, with Calendula's overlay motion: it slides
|
||||
* in on the motion scheme's spring and leaves from its predictive-back preview.
|
||||
* Inside it, back (button or gesture) returns to the hub rather than popping
|
||||
* the whole Settings destination; the sub-screen's scaffold takes it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
|
||||
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
|
||||
// Reduced motion fades it in, as predictiveBackExit already fades it out.
|
||||
val enter = if (rememberReduceMotion()) fadeIn() else slideInHorizontally(slideSpec) { it } + fadeIn()
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally { it } + fadeIn(),
|
||||
exit = slideOutHorizontally { it } + fadeOut(),
|
||||
enter = enter,
|
||||
exit = predictiveBackExit(),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
@@ -16,8 +17,12 @@ internal sealed interface SettingsUiState {
|
||||
val selfCheckProblemCount: Int? = null,
|
||||
/** The world clock's "Display time with seconds" (#23); not a clock default. */
|
||||
val worldClockSeconds: Boolean,
|
||||
/** The world clock's "Compact home clock" (#29); not a clock default either. */
|
||||
val compactHomeClock: 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,19 @@ class SettingsViewModel @Inject internal constructor(
|
||||
settings.appearance,
|
||||
settings.defaults,
|
||||
selfCheckProblemCount,
|
||||
settings.worldClockSeconds,
|
||||
// Grouped, since `combine` stops at five typed flows.
|
||||
combine(settings.worldClockSeconds, settings.compactHomeClock, settings.weekStart, ::Triple),
|
||||
settings.ringOverOtherApps,
|
||||
) { appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps ->
|
||||
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps)
|
||||
) { appearance, defaults, problemCount, (worldClockSeconds, compactHomeClock, weekStart), ringOverOtherApps ->
|
||||
SettingsUiState.Loaded(
|
||||
appearance = appearance,
|
||||
defaults = defaults,
|
||||
selfCheckProblemCount = problemCount,
|
||||
worldClockSeconds = worldClockSeconds,
|
||||
compactHomeClock = compactHomeClock,
|
||||
ringOverOtherApps = ringOverOtherApps,
|
||||
weekStart = weekStart,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
|
||||
|
||||
/**
|
||||
@@ -254,6 +264,11 @@ class SettingsViewModel @Inject internal constructor(
|
||||
fun onWorldClockSecondsChanged(enabled: Boolean) =
|
||||
viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
|
||||
|
||||
fun onWeekStartChanged(pref: WeekStartPref) = viewModelScope.launch { settings.setWeekStart(pref) }
|
||||
|
||||
fun onCompactHomeClockChanged(enabled: Boolean) =
|
||||
viewModelScope.launch { settings.setCompactHomeClock(enabled) }
|
||||
|
||||
fun onRingOverOtherAppsChanged(enabled: Boolean) =
|
||||
viewModelScope.launch { settings.setRingOverOtherApps(enabled) }
|
||||
|
||||
|
||||
@@ -35,12 +35,18 @@ internal fun TimerDefaultsScreen(
|
||||
state: SettingsUiState.Loaded,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
val defaults = state.defaults
|
||||
var showRampPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var silenceAfterOpen by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_timers),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_timer_defaults_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.jeanlucmakiola.clockula.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
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.clockula.ui.common.rememberLocale
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import java.time.format.TextStyle
|
||||
|
||||
/** 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, rememberLocale())
|
||||
}
|
||||
|
||||
/** The row's summary: a pinned day, or "Automatic (Monday)" with the day it resolves to. */
|
||||
@Composable
|
||||
internal fun weekStartSummary(pref: WeekStartPref): String = when (pref) {
|
||||
WeekStartPref.Auto -> stringResource(
|
||||
R.string.settings_week_start_auto_row,
|
||||
weekStartText(WeekStartPref.Day(pref.resolveFirstDay(rememberLocale()))),
|
||||
)
|
||||
is WeekStartPref.Day -> weekStartText(pref)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = rememberLocale()
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
|
||||
@@ -77,10 +79,15 @@ import kotlin.math.sign
|
||||
* state is saveable, so the tab survives rotation and process death with no
|
||||
* mirror of ours to drift — and the `NavHost` holds only what is pushed over
|
||||
* the tabs: the editors, the timer setup page, Settings.
|
||||
*
|
||||
* With [pureBlack] the bar sits on the black ground rather than on
|
||||
* `surfaceContainer`, so the bottom of the screen is black down to the
|
||||
* system navigation bar (#28).
|
||||
*/
|
||||
@Composable
|
||||
fun ClockulaShell(
|
||||
modifier: Modifier = Modifier,
|
||||
pureBlack: Boolean = false,
|
||||
openRequest: ShellRequest? = null,
|
||||
onRequestConsumed: () -> Unit = {},
|
||||
viewModel: ShellViewModel = hiltViewModel(),
|
||||
@@ -192,6 +199,14 @@ fun ClockulaShell(
|
||||
enabled = back is ShellBackAction.GoTo,
|
||||
),
|
||||
navigationSuiteType = navigationSuiteType,
|
||||
navigationSuiteColors = if (pureBlack) {
|
||||
NavigationSuiteDefaults.colors(
|
||||
shortNavigationBarContainerColor = MaterialTheme.colorScheme.background,
|
||||
navigationBarContainerColor = MaterialTheme.colorScheme.background,
|
||||
)
|
||||
} else {
|
||||
NavigationSuiteDefaults.colors()
|
||||
},
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
|
||||
@@ -9,12 +9,14 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||
* colour on API 31+, system light/dark, the standard motion scheme) fed with
|
||||
* Clockula's own identity — its seed-derived fallback schemes
|
||||
* ([ClockulaLightFallback] / [ClockulaDarkFallback]) and [ClockulaTypography].
|
||||
* The mechanics live in floret-kit; the look stays here.
|
||||
* The mechanics live in floret-kit; the look stays here. [pureBlack] is the
|
||||
* AMOLED take on the dark theme (`ThemeMode.BLACK`).
|
||||
*/
|
||||
@Composable
|
||||
fun ClockulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
pureBlack: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
FloretExpressiveTheme(
|
||||
@@ -22,6 +24,7 @@ fun ClockulaTheme(
|
||||
darkScheme = ClockulaDarkFallback,
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = pureBlack,
|
||||
typography = ClockulaTypography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.clockula.ui.widget.config.QuickTimerPicker
|
||||
import de.jeanlucmakiola.clockula.ui.widget.openAppIntent
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -78,7 +79,11 @@ class TilePreferencesActivity : ComponentActivity() {
|
||||
// before the stored duration arrives.
|
||||
val duration by settingsPrefs.quickTileDuration.collectAsState(initial = null)
|
||||
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
|
||||
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
|
||||
ClockulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
pureBlack = appearance.themeMode.isPureBlack,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
val current = duration ?: return@ClockulaTheme
|
||||
QuickTimerPicker(
|
||||
|
||||
+6
-1
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -94,7 +95,11 @@ abstract class WidgetConfigActivity : ComponentActivity() {
|
||||
private fun ConfigTheme(content: @Composable () -> Unit) {
|
||||
val appearance by settingsPrefs.appearance.collectAsState(initial = Appearance())
|
||||
val darkTheme = appearance.themeMode.isDark(isSystemInDarkTheme())
|
||||
ClockulaTheme(darkTheme = darkTheme, dynamicColor = appearance.dynamicColor) {
|
||||
ClockulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
pureBlack = appearance.themeMode.isPureBlack,
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DragHandle
|
||||
import androidx.compose.material.icons.rounded.Home
|
||||
import androidx.compose.material.icons.rounded.NearMe
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
@@ -227,16 +229,70 @@ internal fun LocalClockRow(
|
||||
) {
|
||||
// "Local time · 7 hours ahead": the label is what tells it apart from a
|
||||
// saved city, so it leads the summary rather than hiding in a glyph.
|
||||
val summary = stringResource(R.string.world_clock_summary, stringResource(R.string.world_clock_local), summaryFor(row))
|
||||
val time = row.time?.let(formatTime)
|
||||
// `LocalClock` only offers a zone the device lists, so the time is there;
|
||||
// the fallback keeps TalkBack from reading an empty slot if that changes.
|
||||
// Spoken to the minute, as a saved row is.
|
||||
val spokenTime = row.time?.let(formatSpokenTime)
|
||||
// the pinned row's fallback keeps TalkBack from reading an empty slot if
|
||||
// that changes.
|
||||
PinnedClockRow(
|
||||
icon = Icons.Rounded.NearMe,
|
||||
name = row.name,
|
||||
summary = stringResource(R.string.world_clock_summary, stringResource(R.string.world_clock_local), summaryFor(row)),
|
||||
time = row.time,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
timeStyle = timeStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Home as a row, for the compact home clock (#29): drawn as the local row is,
|
||||
* with a home glyph, and with home's long zone name in the summary where the
|
||||
* face would caption it. Home is not stored in the list either, so it has
|
||||
* nothing to move or remove.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HomeClockRow(
|
||||
home: HomeFaceState,
|
||||
formatTime: (LocalTime) -> String,
|
||||
formatSpokenTime: (LocalTime) -> String,
|
||||
timeStyle: RowTimeStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val homeLabel = stringResource(R.string.world_clock_home)
|
||||
PinnedClockRow(
|
||||
icon = Icons.Rounded.Home,
|
||||
name = home.city,
|
||||
summary = home.zoneName?.let { stringResource(R.string.world_clock_summary, homeLabel, it) } ?: homeLabel,
|
||||
time = home.time,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
timeStyle = timeStyle,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row outside the reorderable column: a glyph where the drag handle would
|
||||
* be, no actions, and a summary allowed two lines — a label is prepended to
|
||||
* it, and on a phone the day ("Tomorrow") or the zone name is what an
|
||||
* ellipsis would otherwise eat. Spoken to the minute, as a saved row is.
|
||||
*/
|
||||
@Composable
|
||||
private fun PinnedClockRow(
|
||||
icon: ImageVector,
|
||||
name: String,
|
||||
summary: String,
|
||||
time: LocalTime?,
|
||||
formatTime: (LocalTime) -> String,
|
||||
formatSpokenTime: (LocalTime) -> String,
|
||||
timeStyle: RowTimeStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spokenTime = time?.let(formatSpokenTime)
|
||||
val description = if (spokenTime != null) {
|
||||
stringResource(R.string.world_clock_row_description, row.name, spokenTime, summary)
|
||||
stringResource(R.string.world_clock_row_description, name, spokenTime, summary)
|
||||
} else {
|
||||
stringResource(R.string.world_clock_row_description_unknown, row.name, summary)
|
||||
stringResource(R.string.world_clock_row_description_unknown, name, summary)
|
||||
}
|
||||
|
||||
GroupedSurface(
|
||||
@@ -253,19 +309,17 @@ internal fun LocalClockRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.NearMe,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
// Two lines, not one: "Local time · " is prepended, and on a phone
|
||||
// the day ("Tomorrow") is what an ellipsis would otherwise eat.
|
||||
RowText(name = row.name, summary = summary, time = time, timeStyle = timeStyle, summaryMaxLines = 2)
|
||||
RowText(name = name, summary = summary, time = time?.let(formatTime), timeStyle = timeStyle, summaryMaxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The name over the summary, then the time: what a saved row and the local row share. */
|
||||
/** The name over the summary, then the time: what a saved row and a pinned row share. */
|
||||
@Composable
|
||||
private fun RowScope.RowText(
|
||||
name: String,
|
||||
|
||||
@@ -59,6 +59,8 @@ internal sealed interface WorldClockUiState {
|
||||
* `WorldClocks.MAX`.
|
||||
*/
|
||||
val local: WorldClockRowState? = null,
|
||||
/** Home drawn as a row like the cities' rather than the analog face (#29). */
|
||||
val compactHome: Boolean = false,
|
||||
) : WorldClockUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,10 @@ import java.time.LocalTime
|
||||
* digits cannot: day or night at home, at a glance. The caption is where the
|
||||
* ICU long zone name is shown, which is the one place it earns its length.
|
||||
*
|
||||
* The compact home clock (#29) trades the face for a row like the cities',
|
||||
* for whoever wants the tab dense and digital throughout. The automatic local
|
||||
* clock then sits under that row exactly as it sits under the face.
|
||||
*
|
||||
* `scrollable` stays true: `ReorderableColumn` is not lazy and cannot live
|
||||
* inside a `LazyColumn`, and a list capped at twenty-four rows is exactly the
|
||||
* shape it was written for (D12).
|
||||
@@ -122,12 +126,23 @@ fun WorldClockScreen(
|
||||
},
|
||||
) {
|
||||
if (loaded != null) {
|
||||
HomeFace(
|
||||
home = loaded.home,
|
||||
showSeconds = loaded.showSeconds,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
)
|
||||
if (loaded.compactHome) {
|
||||
HomeClockRow(
|
||||
home = loaded.home,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
timeStyle = rowTimeStyle,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
} else {
|
||||
HomeFace(
|
||||
home = loaded.home,
|
||||
showSeconds = loaded.showSeconds,
|
||||
formatTime = formatTime,
|
||||
formatSpokenTime = formatSpokenTime,
|
||||
)
|
||||
}
|
||||
|
||||
// Above the saved cities and outside their column: it is not one
|
||||
// of them, and a drag must not be able to land between the two.
|
||||
|
||||
@@ -82,6 +82,9 @@ internal class WorldClockSource @Inject constructor(
|
||||
ticker.alignedTicks(WorldClockDefaults.tickFor(seconds))
|
||||
.map { build(clocks, storedHome, localClock, seconds) }
|
||||
}
|
||||
// Outside the tick: how home is drawn changes nothing it computes,
|
||||
// so flipping it neither restarts the ticker nor waits for it.
|
||||
.combine(settings.compactHomeClock) { loaded, compact -> loaded.copy(compactHome = compact) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
|
||||
@@ -329,6 +329,8 @@
|
||||
<string name="settings_theme_system">Follow system</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_black">Black</string>
|
||||
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||
<string name="settings_dynamic_color_unavailable">Needs Android 12 or newer</string>
|
||||
@@ -355,14 +357,21 @@
|
||||
<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 and clock, 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_compact_home_clock">Compact home clock</string>
|
||||
<string name="settings_compact_home_clock_hint">Show home as a row like the other cities, without the analog clock.</string>
|
||||
<string name="settings_world_clock_seconds">Display time with seconds</string>
|
||||
<string name="settings_world_clock_seconds_hint">The world clock shows seconds and a second hand. Uses a little more battery.</string>
|
||||
<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_auto_row">Automatic (%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,8 @@ class BackupEngineTest {
|
||||
source.settings.setSnoozeMinutes(7)
|
||||
source.settings.setHomeZoneId("America/New_York")
|
||||
source.settings.setWorldClockSeconds(true)
|
||||
source.settings.setWeekStart(WeekStartPref.Day(DayOfWeek.MONDAY))
|
||||
source.settings.setCompactHomeClock(true)
|
||||
|
||||
val exported = source.backupEngine.export()
|
||||
|
||||
@@ -70,6 +74,8 @@ 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))
|
||||
assertThat(target.settings.compactHomeClock.first()).isTrue()
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -79,8 +81,10 @@ class BackupCodecTest {
|
||||
),
|
||||
timerPresets = listOf(1.minutes, 2.minutes, 10.minutes),
|
||||
worldClockSeconds = true,
|
||||
compactHomeClock = 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 +198,27 @@ 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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null week start follows the locale rather than rejecting the file`() {
|
||||
val decoded = BackupCodec.decode(settingsOnly(""""week_start": null"""))
|
||||
|
||||
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 } }"""
|
||||
|
||||
@@ -483,6 +508,17 @@ class BackupCodecTest {
|
||||
assertThat(BackupCodec.encode(sampleContent())).contains("\"world_clock_show_seconds\": true")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before the compact home clock keeps it off`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
assertThat(settings.compactHomeClock).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the compact home clock is written under its preference key`() {
|
||||
assertThat(BackupCodec.encode(sampleContent())).contains("\"world_clock_compact_home\": true")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file from before the automatic local clock keeps it on`() {
|
||||
val settings = (BackupCodec.decode(minimalDocument()) as BackupResult.Success).content.settings
|
||||
|
||||
@@ -115,6 +115,25 @@ class ClockPrefsTest {
|
||||
assertThat(prefs.worldClockSeconds.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the compact home clock round-trips without touching the clock defaults`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
assertThat(prefs.compactHomeClock.first()).isFalse()
|
||||
|
||||
prefs.defaults.test {
|
||||
assertThat(awaitItem()).isEqualTo(ClockDefaults())
|
||||
|
||||
prefs.setCompactHomeClock(true)
|
||||
// A barrier on this same flow, as for show-seconds above.
|
||||
prefs.setSnoozeMinutes(7)
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(ClockDefaults(snoozeMinutes = 7))
|
||||
expectNoEvents()
|
||||
cancel()
|
||||
}
|
||||
assertThat(prefs.compactHomeClock.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snooze minutes clamp on write`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
@@ -77,6 +79,15 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pure-black theme mode round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
|
||||
prefs.setThemeMode(ThemeMode.BLACK)
|
||||
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.BLACK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup settings are applied together using normal preference clamps`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
|
||||
@@ -97,8 +108,10 @@ class SettingsPrefsTest {
|
||||
),
|
||||
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
|
||||
worldClockSeconds = false,
|
||||
compactHomeClock = true,
|
||||
quickTileDuration = 2.minutes,
|
||||
screenSaver = ScreenSaverSettings(ScreenSaverStyle.ANALOG, nightMode = true),
|
||||
weekStart = WeekStartPref.Day(DayOfWeek.MONDAY),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -113,9 +126,53 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.currentDefaults().timerVibrate).isFalse()
|
||||
assertThat(prefs.currentDefaults().timerVolumeRampSeconds).isEqualTo(60)
|
||||
assertThat(prefs.currentDefaults().automaticLocalClock).isFalse()
|
||||
assertThat(prefs.compactHomeClock.first()).isTrue()
|
||||
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,32 @@ 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 `the compact home clock switch writes through`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val viewModel = collecting(newViewModel(tempDir))
|
||||
advanceUntilIdle()
|
||||
assertThat((viewModel.state.value as SettingsUiState.Loaded).compactHomeClock).isFalse()
|
||||
|
||||
viewModel.onCompactHomeClockChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((viewModel.state.value as SettingsUiState.Loaded).compactHomeClock).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing a default alarm ringtone writes through and closes the picker`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
|
||||
@@ -420,6 +420,21 @@ class WorldClockViewModelTest {
|
||||
assertThat(harness.ticker.requestedPeriods).isEmpty()
|
||||
}
|
||||
|
||||
/** #29: how home is drawn is not something the tab ticks for. */
|
||||
@Test
|
||||
fun `the compact home clock reaches the tab without restarting the ticker`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = collecting(worldClockHarness(tempDir))
|
||||
advanceUntilIdle()
|
||||
assertThat(harness.loaded().compactHome).isFalse()
|
||||
|
||||
harness.settings.setCompactHomeClock(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.loaded().compactHome).isTrue()
|
||||
assertThat(harness.ticker.requestedUnits).containsExactly(WorldClockDefaults.tickFor(showSeconds = false))
|
||||
}
|
||||
|
||||
/** #23: the tab wakes every second only while the user asked for seconds. */
|
||||
@Test
|
||||
fun `turning seconds on and off moves the cadence with it`(@TempDir tempDir: Path) =
|
||||
|
||||
@@ -618,7 +618,8 @@ 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>` |
|
||||
| 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 |
|
||||
| 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, #29) | `world_clock_show_seconds`, `world_clock_compact_home` | `ClockPrefs`, surfaced as their own `SettingsPrefs.worldClockSeconds` and `SettingsPrefs.compactHomeClock`, not clock defaults, so flipping them 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 |
|
||||
| Timers (M6) | `timer_presets`, `timer_ring_sounding_since_millis` | `TimerPrefs`: the presets surfaced as `SettingsPrefs.timerPresets`, the ring session's anchor behind `TimerRingStateStore` |
|
||||
|
||||
+9
-2
@@ -164,6 +164,7 @@ device or after a reboot (`docs/PLAN.md` §5). Every imported timer lands
|
||||
"default_timer_silence_after_minutes": 10,
|
||||
"volume_buttons_while_ringing": "CONTROL_VOLUME",
|
||||
"world_clock_show_seconds": false,
|
||||
"world_clock_compact_home": false,
|
||||
"automatic_local_clock": true,
|
||||
"timer_presets": [60000, 120000, 180000, 300000, 600000, 900000, 1800000, 3600000],
|
||||
"bedtime": {
|
||||
@@ -179,7 +180,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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -213,9 +215,14 @@ rings for that long anyway); a negative one reads as the default, 10.
|
||||
|
||||
Files from before `world_clock_show_seconds` existed import with it off, its
|
||||
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`
|
||||
alarm or timer inherits depends on it. The same goes for
|
||||
`world_clock_compact_home` (#29), which a file without it restores as off. 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 or `null`, 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: 02bd14abdb...b8e37a61bc.
Reference in new issue
Block a user