Author SHA1 Message Date
makiolaj a81a3641ce chore(release): 1.0.0-beta.8
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 11m52s
2026-11-02 20:15:55 +01:00
makiolaj 4a29346351 chore: move floret-kit to main after the SnackChip fix (floret-kit#26) 2026-11-02 20:10:53 +01:00
makiolaj 0148031907 fix: review findings on week start and settings motion
- The last week start is saved with the activity, so a rotation no
  longer draws one frame in the locale's order before the stored day.
- A backup with "week_start": null restores Automatic instead of
  rejecting the whole file.
- Under reduced motion a settings sub-page fades in, matching its exit.
- LocalWeekStart, rememberWeekStart and one shared rememberLocale move
  to ui/common; the picker, the day buttons and the summaries use them.
- The "Week starts on" row shows the day Automatic resolves to.
2026-11-02 19:40:06 +01:00
makiolaj fe17bcc8e8 Merge branch 'feat/29-world-clock-list' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/de/jeanlucmakiola/clockula/backup/BackupEngineTest.kt
#	app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt
#	docs/ARCHITECTURE.md
2026-11-02 19:30:34 +01:00
makiolaj 381f2b8f69 Merge branch 'feat/30-week-start-impl' into release/v1.0.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt
2026-11-02 19:29:55 +01:00
makiolaj 8c5c664cd9 Merge branch 'feat/32-settings-back-motion' into release/v1.0.0 2026-11-02 19:29:43 +01:00
makiolaj f3789fc164 Merge branch 'fix/35-snack-chip-shadow' into release/v1.0.0 2026-11-02 19:29:43 +01:00
makiolaj 7d65b2b206 feat(world-clock): an optional compact home clock
A "Compact home clock" switch under Settings > Clock swaps the World clock
tab's analog face for a row: a home glyph, the city, "Home · <zone name>"
and the time, in the seconds-aware style the city rows use and spoken to
the minute like them. It is drawn the way the automatic local clock's row
is, through one shared pinned-row composable, and the local row still sits
directly under home. Off by default.

Like show-seconds it is display-only, so it is its own preference and flow
(world_clock_compact_home) rather than a clock default, and it is applied
outside the tick so flipping it does not restart the ticker. Backups carry
it; older files restore it as off.

Closes #29.
2026-11-02 19:29:16 +01:00
makiolaj 3e74d03ea7 feat(settings): a "Week starts on" setting
Settings > Clock gets a "Week starts on" row: Automatic, which follows
the locale (and with it Android 14's regional first-day preference), or
any of the seven days, after Calendula's own. The repeat-day buttons in
the alarm editor and on the bedtime screen, and the alarm list's day
summaries, start on the chosen day. MainActivity reads the preference
once and provides it as LocalWeekStart.

Stored under Calendula's key name, week_start, and carried in the
backup; a file without it, or with a value this version does not know,
restores Automatic.

Closes #30.
2026-11-02 19:27:55 +01:00
makiolaj d749bc0b7b feat(settings): sub-pages leave with the predictive-back motion
A settings sub-page now previews the back gesture and leaves fading and
shrinking toward the hub, as in Calendula and Agendula, instead of
sliding out. The sub-page's scaffold takes back, replacing the hub's
BackHandler. Closes #32.
2026-11-02 19:17:29 +01:00
makiolaj 5d7876684d fix(ui): the Undo chip's shadow no longer shows square
Moves the floret-kit pin to the SnackChip fix (floret-kit#26).
2026-11-02 19:15:34 +01:00
makiolaj 005df7db19 chore(release): 1.0.0-beta.7
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Successful in 12m6s
2026-11-02 18:53:09 +01:00
makiolaj 0a92dce8a9 Merge branch 'fix/black-nav-bar' into release/v1.0.0 2026-11-02 18:51:05 +01:00
makiolaj cd705961ca fix(appearance): a black navigation bar in the Black theme
The tab bar sat on surfaceContainer, which the Black theme only dims, and
the three-button scrim was the dark theme's grey. Both are black now, so
the bottom of the screen is black down to the system bar (#28).
2026-11-02 18:48:53 +01:00
makiolaj a390296dc2 chore(release): 1.0.0-beta.6
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 12m8s
2026-10-28 14:46:02 +01:00
makiolaj c4fd0476e4 Merge branch 'feat/pure-black-theme' into release/v1.0.0 2026-10-28 14:46:02 +01:00
makiolaj b03b23da30 feat(appearance): a Black theme for AMOLED screens
Adds Black next to Light and Dark in the theme picker: the dark theme
with pure black backgrounds, applied wherever the app theme is used.
Moves floret-kit to main, which brings ThemeMode.BLACK and the theme's
pureBlack, and has the submodule track main instead of feat/clockula.
2026-10-28 14:46:02 +01:00
43 changed files with 725 additions and 98 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -26,8 +26,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
Undo brings it back with all its settings, and a snoozed alarm comes back
snoozed.
- Repeat days that start on your locale's first day of the week, and a "skip
next alarm" switch so you can sleep in once without turning the alarm off.
- Repeat days that start on your locale's first day of the week, or on any
day you pick under "Week starts on" in Settings > Clock, and a "skip next
alarm" switch so you can sleep in once without turning the alarm off.
- An "Upcoming alarm" notification two hours before an alarm rings, with a
Dismiss button, so you can turn off today's alarm when you're already awake.
A repeating alarm skips just that once; a one-time alarm is switched off.
@@ -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
+2 -2
View File
@@ -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() }
}
@@ -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(
@@ -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()
/**
+10 -1
View File
@@ -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) =
+2 -1
View File
@@ -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
View File
@@ -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.