feat(settings): a settings hub — appearance, alarm/timer/clock defaults, data, help

Reachable from a gear icon on every tab, composed from kit components
(GroupedRow/OptionPicker/AboutCard/LanguagePickerRow) following
Calendula's hub shape: About card first, then headed groups. Every
control writes immediately — no Save button, same as the alarm editor.

Appearance: theme, dynamic colour (API 31+), language. Alarm and timer
defaults reuse the editor's own ringtone and override pickers
(OverridePickers gets an includeAppDefault flag so the settings rows
don't offer an 'inherit' option that has nothing above it to inherit
from) and the world clock's home-zone picker. Reliability and Data
rows are placeholders for the self-check and backup screens landing
next. Help surfaces the issue tracker, a pending crash report (the
same dialog MainActivity already shows on launch), and the app
version.

Settings has no owning tab — ShellNavigation.selectedDestinationOf
deliberately leaves it unmapped, so the shell falls back to Alarms
while it's open, same as any other unrecognised route.
This commit is contained in:
2026-10-01 17:36:06 +02:00
parent 0664791ac5
commit 7253445c4e
18 changed files with 1008 additions and 8 deletions
@@ -42,8 +42,8 @@ import javax.inject.Inject
class MainActivity : ComponentActivity() {
// Field-injected rather than read through a ViewModel: the appearance feeds
// the theme before any screen exists. The settings ViewModel arrives with
// the settings screen in M10.
// the theme before any screen exists, which is before the settings screen's
// own ViewModel (ui/settings/SettingsViewModel, M10) is ever created.
@Inject
lateinit var settingsPrefs: SettingsPrefs
@@ -9,8 +9,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -43,6 +45,7 @@ import kotlinx.coroutines.flow.collectLatest
fun AlarmsScreen(
onEditAlarm: (Long) -> Unit,
onCreateAlarm: (Long) -> Unit,
onOpenSettings: () -> Unit = {},
modifier: Modifier = Modifier,
dismissCandidates: LongArray = LongArray(0),
onDismissCandidatesConsumed: () -> Unit = {},
@@ -63,6 +66,14 @@ fun AlarmsScreen(
title = stringResource(R.string.tab_alarms),
modifier = modifier,
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::onAddAlarm,
@@ -73,6 +73,7 @@ internal fun VibratePicker(
default: Boolean,
onSelect: (Boolean?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) = OverridePicker(
title = stringResource(R.string.editor_vibrate),
options = OverrideOptions.Vibrate,
@@ -81,6 +82,7 @@ internal fun VibratePicker(
text = { vibrateText(it) },
onSelect = onSelect,
onDismiss = onDismiss,
includeAppDefault = includeAppDefault,
)
@Composable
@@ -89,6 +91,7 @@ internal fun VolumeRampPicker(
default: Int,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) = OverridePicker(
title = stringResource(R.string.editor_volume_ramp),
options = OverrideOptions.VolumeRampSeconds,
@@ -97,6 +100,7 @@ internal fun VolumeRampPicker(
text = { volumeRampText(it) },
onSelect = onSelect,
onDismiss = onDismiss,
includeAppDefault = includeAppDefault,
)
@Composable
@@ -105,6 +109,7 @@ internal fun SnoozeLengthPicker(
default: Int,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) = OverridePicker(
title = stringResource(R.string.editor_snooze_length),
options = OverrideOptions.SnoozeMinutes,
@@ -113,6 +118,7 @@ internal fun SnoozeLengthPicker(
text = { snoozeMinutesText(it) },
onSelect = onSelect,
onDismiss = onDismiss,
includeAppDefault = includeAppDefault,
)
@Composable
@@ -121,6 +127,7 @@ internal fun SnoozeLimitPicker(
default: Int,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) = OverridePicker(
title = stringResource(R.string.editor_snooze_limit),
options = OverrideOptions.SnoozeLimit,
@@ -129,6 +136,7 @@ internal fun SnoozeLimitPicker(
text = { snoozeLimitText(it) },
onSelect = onSelect,
onDismiss = onDismiss,
includeAppDefault = includeAppDefault,
)
@Composable
@@ -137,6 +145,7 @@ internal fun DismissChallengePicker(
default: DismissChallenge,
onSelect: (DismissChallenge?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) = OverridePicker(
title = stringResource(R.string.editor_dismiss_challenge),
options = OverrideOptions.DismissChallenges,
@@ -145,11 +154,18 @@ internal fun DismissChallengePicker(
text = { challengeText(it) },
onSelect = onSelect,
onDismiss = onDismiss,
includeAppDefault = includeAppDefault,
)
/**
* One shape for all five: the "App default" rung names what the default
* currently is, so choosing to inherit is not choosing blind.
*
* [includeAppDefault] drops that rung for a caller with no higher-level
* default to inherit from — the settings screen (M10), which edits the
* default itself rather than an override of it. `stored` is then never null
* in practice (there is nothing to fall back to), so the options list is
* simply `options` without its leading `null`.
*/
@Composable
private fun <T : Any> OverridePicker(
@@ -160,10 +176,11 @@ private fun <T : Any> OverridePicker(
text: @Composable (T) -> String,
onSelect: (T?) -> Unit,
onDismiss: () -> Unit,
includeAppDefault: Boolean = true,
) {
OptionPicker(
title = title,
options = options,
options = if (includeAppDefault) options else options.filterNotNull(),
selected = stored,
label = { option ->
if (option == null) {
@@ -48,6 +48,12 @@ internal fun RingtonePickerScreen(
onPreview: (String?) -> Unit,
onPickFile: () -> Unit,
onDismiss: () -> Unit,
// Overrides the AppDefault row's title. The alarm/timer editors inherit an
// *app* default, so "App default" is correct there; a caller editing that
// default itself (Settings, M10) has nothing above it to inherit — only a
// device default — so it passes R.string.ringtone_device_default here and
// defaultUri = null, reusing the same row for a different rung.
appDefaultLabel: String = stringResource(R.string.override_app_default),
) {
FullScreenPicker(
title = stringResource(R.string.ringtone_picker_title),
@@ -79,7 +85,7 @@ internal fun RingtonePickerScreen(
)
}
}
ringtoneOptions(state, rows, onChoose, onPreview, defaultUri)
ringtoneOptions(state, rows, onChoose, onPreview, defaultUri, appDefaultLabel)
item {
GroupedRow(
title = stringResource(R.string.ringtone_pick_file),
@@ -97,6 +103,7 @@ private fun LazyListScope.ringtoneOptions(
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
defaultUri: String?,
appDefaultLabel: String,
) {
state.options.forEachIndexed { index, option ->
item(key = optionKey(option)) {
@@ -106,6 +113,7 @@ private fun LazyListScope.ringtoneOptions(
selected = RingtonePicker.isSelected(option, state.selectedUri),
defaultUri = defaultUri,
defaultTitle = state.defaultTitle,
appDefaultLabel = appDefaultLabel,
onChoose = onChoose,
onPreview = onPreview,
)
@@ -126,6 +134,7 @@ private fun RingtoneOptionRow(
selected: Boolean,
defaultUri: String?,
defaultTitle: String?,
appDefaultLabel: String,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
) {
@@ -133,7 +142,7 @@ private fun RingtoneOptionRow(
val deviceDefault = stringResource(R.string.ringtone_device_default)
val silentTitle = stringResource(R.string.ringtone_silent)
val title = when (option) {
RingtoneChoice.AppDefault -> stringResource(R.string.override_app_default)
RingtoneChoice.AppDefault -> appDefaultLabel
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent)
is RingtoneChoice.Sound -> option.title.ifBlank { unknown }
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.ui.settings
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
/**
* Placeholder for backup export/import — the real screen lands with M10's
* backup phase. Same route, same call shape, so swapping it in is a one-file
* change.
*/
@Composable
fun BackupScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
CollapsingScaffold(
title = stringResource(R.string.settings_backup_title),
onBack = onBack,
modifier = modifier,
) {
Text(stringResource(R.string.settings_coming_soon))
}
}
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.clockula.ui.settings
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
/**
* Placeholder for the "why might my alarm not ring?" self-check — the real
* screen lands with M10's self-check phase. Same route, same call shape, so
* swapping it in is a one-file change.
*/
@Composable
fun SelfCheckScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
CollapsingScaffold(
title = stringResource(R.string.settings_self_check_title),
onBack = onBack,
modifier = modifier,
) {
Text(stringResource(R.string.settings_coming_soon))
}
}
@@ -0,0 +1,8 @@
package de.jeanlucmakiola.clockula.ui.settings
/** Settings hub and its two sub-screens. No arguments: each is a singleton page (M10). */
internal object SettingsRoutes {
const val MAIN: String = "settings"
const val SELF_CHECK: String = "settings/self_check"
const val BACKUP: String = "settings/backup"
}
@@ -0,0 +1,388 @@
package de.jeanlucmakiola.clockula.ui.settings
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccessTime
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Send
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLengthPicker
import de.jeanlucmakiola.clockula.ui.alarms.SnoozeLimitPicker
import de.jeanlucmakiola.clockula.ui.alarms.VibratePicker
import de.jeanlucmakiola.clockula.ui.alarms.VolumeRampPicker
import de.jeanlucmakiola.clockula.ui.alarms.challengeText
import de.jeanlucmakiola.clockula.ui.alarms.snoozeLimitText
import de.jeanlucmakiola.clockula.ui.alarms.snoozeMinutesText
import de.jeanlucmakiola.clockula.ui.alarms.vibrateText
import de.jeanlucmakiola.clockula.ui.alarms.volumeRampText
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerScreen
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerScreen
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
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.prefs.ThemeMode
import kotlin.time.Duration
/** Which of the alarm-default pickers is open. Mirrors [de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen]'s shape. */
private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
private const val AUDIO_MIME = "audio/*"
/**
* The settings hub: appearance, the alarm/timer/clock defaults every new alarm
* or timer inherits, the reliability and backup sub-screens, and help. Every
* control writes at once — there is no Save button here either (M5 D22).
*/
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onOpenSelfCheck: () -> Unit,
onOpenBackup: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val ringtonePicker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
val zonePicker by viewModel.zonePicker.collectAsStateWithLifecycle()
val context = LocalContext.current
var openAlarmPicker by rememberSaveable { mutableStateOf<OpenAlarmPicker?>(null) }
var themePickerOpen by rememberSaveable { mutableStateOf(false) }
var timerDurationPickerOpen by rememberSaveable { mutableStateOf(false) }
var pendingCrashReport by remember { mutableStateOf(CrashReporter.pendingReport(context)) }
var showCrashDialog by rememberSaveable { mutableStateOf(false) }
// Registered unconditionally, for the same reason the alarm editor's is
// (M5 D20): the picker's open flag is ViewModel state that does not
// survive process death.
val pickDocument = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> viewModel.onDocumentPicked(uri?.toString()) }
val loaded = state as? SettingsUiState.Loaded
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, modifier = modifier) {
AboutCard(
logo = { Icon(Icons.Rounded.Alarm, contentDescription = null) },
appName = stringResource(R.string.app_name),
author = stringResource(R.string.settings_author),
primaryLinks = listOf(
AboutLink(
icon = Icons.Rounded.Code,
label = stringResource(R.string.settings_source),
url = stringResource(R.string.settings_source_url),
),
),
)
if (loaded != null) {
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = themeModeLabel(loaded.appearance.themeMode),
position = positionOf(0, if (dynamicColorSupported) 3 else 2),
leading = { Icon(Icons.Rounded.Palette, contentDescription = null) },
onClick = { themePickerOpen = true },
)
if (dynamicColorSupported) {
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
position = positionOf(1, 3),
trailing = {
Switch(
checked = loaded.appearance.dynamicColor,
onCheckedChange = viewModel::onDynamicColorChanged,
)
},
onClick = { viewModel.onDynamicColorChanged(!loaded.appearance.dynamicColor) },
)
}
LanguagePickerRow(
position = Position.Bottom,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
leading = { Icon(Icons.Rounded.Language, contentDescription = null) },
)
GroupedSectionHeader(stringResource(R.string.settings_section_alarm_defaults))
GroupedRow(
title = stringResource(R.string.editor_ringtone),
summary = ringtoneSummary(loaded.defaults.alarmRingtoneUri),
position = positionOf(0, 6),
onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.ALARM) },
)
GroupedRow(
title = stringResource(R.string.editor_vibrate),
summary = vibrateText(loaded.defaults.vibrate),
position = positionOf(1, 6),
onClick = { openAlarmPicker = OpenAlarmPicker.VIBRATE },
)
GroupedRow(
title = stringResource(R.string.editor_volume_ramp),
summary = volumeRampText(loaded.defaults.volumeRampSeconds),
position = positionOf(2, 6),
onClick = { openAlarmPicker = OpenAlarmPicker.VOLUME_RAMP },
)
GroupedRow(
title = stringResource(R.string.editor_snooze_length),
summary = snoozeMinutesText(loaded.defaults.snoozeMinutes),
position = positionOf(3, 6),
onClick = { openAlarmPicker = OpenAlarmPicker.SNOOZE_LENGTH },
)
GroupedRow(
title = stringResource(R.string.editor_snooze_limit),
summary = snoozeLimitText(loaded.defaults.snoozeLimit),
position = positionOf(4, 6),
onClick = { openAlarmPicker = OpenAlarmPicker.SNOOZE_LIMIT },
)
GroupedRow(
title = stringResource(R.string.editor_dismiss_challenge),
summary = challengeText(loaded.defaults.dismissChallenge),
position = positionOf(5, 6),
onClick = { openAlarmPicker = OpenAlarmPicker.CHALLENGE },
)
GroupedSectionHeader(stringResource(R.string.settings_section_timer_defaults))
GroupedRow(
title = stringResource(R.string.editor_ringtone),
summary = ringtoneSummary(loaded.defaults.timerRingtoneUri),
position = positionOf(0, 2),
leading = { Icon(Icons.Rounded.Timer, contentDescription = null) },
onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) },
)
GroupedRow(
title = stringResource(R.string.settings_timer_duration),
summary = durationMinutesText(loaded.defaults.timerDuration),
position = positionOf(1, 2),
onClick = { timerDurationPickerOpen = true },
)
GroupedSectionHeader(stringResource(R.string.settings_section_clock))
GroupedRow(
title = stringResource(R.string.settings_home_zone),
summary = loaded.defaults.homeZoneId ?: stringResource(R.string.zone_picker_device_zone),
position = Position.Alone,
leading = { Icon(Icons.Rounded.Public, contentDescription = null) },
onClick = viewModel::onOpenHomeZonePicker,
)
}
GroupedSectionHeader(stringResource(R.string.settings_section_reliability))
GroupedRow(
title = stringResource(R.string.settings_self_check),
summary = stringResource(R.string.settings_self_check_summary),
position = Position.Alone,
leading = { Icon(Icons.Rounded.AccessTime, contentDescription = null) },
onClick = onOpenSelfCheck,
)
GroupedSectionHeader(stringResource(R.string.settings_section_data))
GroupedRow(
title = stringResource(R.string.settings_backup),
summary = stringResource(R.string.settings_backup_summary),
position = Position.Alone,
onClick = onOpenBackup,
)
GroupedSectionHeader(stringResource(R.string.settings_section_help))
val helpRows = if (pendingCrashReport != null) 3 else 2
GroupedRow(
title = stringResource(R.string.settings_report_issue),
position = positionOf(0, helpRows),
leading = { Icon(Icons.Rounded.BugReport, contentDescription = null) },
onClick = { openIssueTracker(context) },
)
if (pendingCrashReport != null) {
GroupedRow(
title = stringResource(R.string.settings_send_crash_report),
position = positionOf(1, helpRows),
leading = { Icon(Icons.Rounded.Send, contentDescription = null) },
onClick = { showCrashDialog = true },
)
}
GroupedRow(
title = stringResource(R.string.settings_version),
summary = appVersionName(),
position = positionOf(helpRows - 1, helpRows),
leading = { Icon(Icons.Rounded.Info, contentDescription = null) },
)
}
if (loaded != null && themePickerOpen) {
OptionPicker(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries.toList(),
selected = loaded.appearance.themeMode,
label = { themeModeLabel(it) },
onSelect = viewModel::onThemeModeSelected,
onDismiss = { themePickerOpen = false },
)
}
if (loaded != null && timerDurationPickerOpen) {
OptionPicker(
title = stringResource(R.string.settings_timer_duration),
options = TimerPresets.BUILT_IN,
selected = loaded.defaults.timerDuration,
label = { durationMinutesText(it) },
onSelect = viewModel::onTimerDurationChanged,
onDismiss = { timerDurationPickerOpen = false },
)
}
if (loaded != null) {
when (openAlarmPicker) {
null -> Unit
OpenAlarmPicker.VIBRATE -> VibratePicker(
stored = loaded.defaults.vibrate,
default = loaded.defaults.vibrate,
includeAppDefault = false,
onSelect = { viewModel.onVibrateChanged(it ?: loaded.defaults.vibrate) },
onDismiss = { openAlarmPicker = null },
)
OpenAlarmPicker.VOLUME_RAMP -> VolumeRampPicker(
stored = loaded.defaults.volumeRampSeconds,
default = loaded.defaults.volumeRampSeconds,
includeAppDefault = false,
onSelect = { viewModel.onVolumeRampChanged(it ?: loaded.defaults.volumeRampSeconds) },
onDismiss = { openAlarmPicker = null },
)
OpenAlarmPicker.SNOOZE_LENGTH -> SnoozeLengthPicker(
stored = loaded.defaults.snoozeMinutes,
default = loaded.defaults.snoozeMinutes,
includeAppDefault = false,
onSelect = { viewModel.onSnoozeMinutesChanged(it ?: loaded.defaults.snoozeMinutes) },
onDismiss = { openAlarmPicker = null },
)
OpenAlarmPicker.SNOOZE_LIMIT -> SnoozeLimitPicker(
stored = loaded.defaults.snoozeLimit,
default = loaded.defaults.snoozeLimit,
includeAppDefault = false,
onSelect = { viewModel.onSnoozeLimitChanged(it ?: loaded.defaults.snoozeLimit) },
onDismiss = { openAlarmPicker = null },
)
OpenAlarmPicker.CHALLENGE -> DismissChallengePicker(
stored = loaded.defaults.dismissChallenge,
default = loaded.defaults.dismissChallenge,
includeAppDefault = false,
onSelect = { viewModel.onDismissChallengeChanged(it ?: loaded.defaults.dismissChallenge) },
onDismiss = { openAlarmPicker = null },
)
}
}
ringtonePicker?.let { (_, pickerState) ->
RingtonePickerScreen(
state = pickerState,
defaultUri = null,
appDefaultLabel = stringResource(R.string.ringtone_device_default),
onChoose = viewModel::onRingtoneChosen,
onPreview = viewModel::onPreview,
onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) },
onDismiss = viewModel::onCloseRingtonePicker,
)
}
zonePicker?.let { open ->
ZonePickerScreen(
state = open,
onQueryChange = viewModel::onZoneQueryChanged,
onPickZone = viewModel::onHomeZonePicked,
onPickDeviceZone = viewModel::onHomeDeviceZonePicked,
onDismiss = viewModel::onDismissZonePicker,
)
}
if (showCrashDialog) {
val report = pendingCrashReport
if (report == null) {
showCrashDialog = false
} else {
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(context, report)
CrashReporter.clearReport(context)
pendingCrashReport = null
showCrashDialog = false
},
onDismiss = { showCrashDialog = false },
)
}
}
}
private val dynamicColorSupported: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@Composable
private fun themeModeLabel(mode: ThemeMode): String = stringResource(
when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
},
)
@Composable
private fun ringtoneSummary(uri: String?): String = when {
uri == null -> stringResource(R.string.ringtone_device_default)
Ringtones.isSilent(uri) -> stringResource(R.string.ringtone_silent)
else -> stringResource(R.string.settings_ringtone_custom)
}
@Composable
private fun durationMinutesText(duration: Duration): String {
val minutes = duration.inWholeMinutes.toInt()
return pluralStringResource(R.plurals.settings_duration_minutes, minutes, minutes)
}
@Composable
private fun appVersionName(): String {
val context = LocalContext.current
val versionName = remember(context) {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
}
return stringResource(R.string.settings_version_value, versionName)
}
@@ -0,0 +1,13 @@
package de.jeanlucmakiola.clockula.ui.settings
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.floret.prefs.Appearance
/** The settings hub's state: the appearance choices and the clock defaults. */
internal sealed interface SettingsUiState {
data object Loading : SettingsUiState
data class Loaded(val appearance: Appearance, val defaults: ClockDefaults) : SettingsUiState
}
/** Which of the two default ringtones the picker is editing. */
internal enum class DefaultRingtoneKind { ALARM, TIMER }
@@ -0,0 +1,212 @@
package de.jeanlucmakiola.clockula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
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.Zones
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerMode
import de.jeanlucmakiola.clockula.ui.worldclock.ZonePickerUiState
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration
import javax.inject.Inject
/**
* The settings hub: every write lands at once, exactly as the alarm editor's
* does (M5 D22) — there is no Save button here either. The ringtone and home
* time-zone pickers reuse the same machinery the alarm editor and the world
* clock tab already built, rather than duplicating it: `RingtonePicker`/
* `RingtonePickerUiState` (`ui/common`) and `WorldClockSource`'s HOME-mode
* picker (`ui/worldclock`) are `internal`, so this module-internal reuse
* costs nothing beyond the import.
*/
@HiltViewModel
class SettingsViewModel @Inject internal constructor(
private val settings: SettingsPrefs,
private val ringtones: RingtoneCatalog,
private val preview: RingtonePreviewer,
private val worldClocks: WorldClockSource,
) : ViewModel() {
internal val state: StateFlow<SettingsUiState> = combine(
settings.appearance,
settings.defaults,
) { appearance, defaults ->
SettingsUiState.Loaded(appearance, defaults)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
private val _ringtonePicker = MutableStateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?>(null)
/** Null while closed; the kind being edited and its picker state while open. */
internal val ringtonePicker: StateFlow<Pair<DefaultRingtoneKind, RingtonePickerUiState>?> =
_ringtonePicker.asStateFlow()
/** What the previewer is sounding, so the next preview knows to stop it first. */
private var previewing: String? = null
private val zoneQuery = MutableStateFlow("")
private val zonePickerOpen = MutableStateFlow(false)
/** Null while closed. Reuses [WorldClockSource]'s HOME-mode picker verbatim. */
@OptIn(ExperimentalCoroutinesApi::class)
internal val zonePicker: StateFlow<ZonePickerUiState?> = zonePickerOpen
.flatMapLatest { open ->
if (!open) flowOf(null) else worldClocks.pickerState(ZonePickerMode.HOME, zoneQuery)
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun onThemeModeSelected(mode: ThemeMode) = viewModelScope.launch { settings.setThemeMode(mode) }
fun onDynamicColorChanged(enabled: Boolean) = viewModelScope.launch { settings.setDynamicColor(enabled) }
fun onVibrateChanged(value: Boolean) = viewModelScope.launch { settings.setVibrate(value) }
fun onSnoozeMinutesChanged(value: Int) = viewModelScope.launch { settings.setSnoozeMinutes(value) }
fun onSnoozeLimitChanged(value: Int) = viewModelScope.launch { settings.setSnoozeLimit(value) }
fun onVolumeRampChanged(value: Int) = viewModelScope.launch { settings.setVolumeRampSeconds(value) }
fun onDismissChallengeChanged(value: DismissChallenge) =
viewModelScope.launch { settings.setDismissChallenge(value) }
fun onTimerDurationChanged(value: Duration) = viewModelScope.launch { settings.setTimerDuration(value) }
// --- the default ringtone pickers ---
internal fun onOpenRingtonePicker(kind: DefaultRingtoneKind) {
val loaded = state.value as? SettingsUiState.Loaded ?: return
val stored = loaded.defaults.storedUriFor(kind)
_ringtonePicker.value = kind to RingtonePickerUiState(
loading = true,
options = emptyList(),
selectedUri = stored,
deviceHasNoSounds = false,
defaultTitle = null,
)
viewModelScope.launch {
val tones = ringtones.alarmTones()
val title = stored
?.takeUnless { Ringtones.isSilent(it) }
?.let { ringtones.titleOf(it) }
_ringtonePicker.value = kind to RingtonePickerUiState(
loading = false,
options = RingtonePicker.optionsFor(tones, stored, title),
selectedUri = stored,
deviceHasNoSounds = tones.isEmpty(),
defaultTitle = null,
)
}
}
fun onCloseRingtonePicker() {
stopPreview()
_ringtonePicker.value = null
}
/** null = device default; [Ringtones.SILENT_URI] = Silent; otherwise a sound. */
fun onRingtoneChosen(uri: String?) {
val kind = _ringtonePicker.value?.first ?: return
onCloseRingtonePicker()
val normalised = Ringtones.normalise(uri)
viewModelScope.launch { setDefaultRingtone(kind, normalised) }
}
/** The SAF result as a String. null = cancelled, which changes nothing. */
fun onDocumentPicked(uri: String?) {
val kind = _ringtonePicker.value?.first ?: return
val picked = Ringtones.normalise(uri) ?: return
onCloseRingtonePicker()
viewModelScope.launch {
// Unlike the alarm editor, the default itself has no fallback
// override to disclose a lapsed grant through — it simply tries to
// persist one, same as the editor, and stores the URI regardless.
ringtones.persistPickedDocument(picked)
setDefaultRingtone(kind, picked)
}
}
/** null or the silent sentinel stops rather than plays. */
fun onPreview(uri: String?) {
val sound = Ringtones.normalise(uri)?.takeUnless { Ringtones.isSilent(it) }
if (sound == null) {
stopPreview()
return
}
if (previewing != null) stopPreview()
previewing = sound
viewModelScope.launch { preview.play(sound) }
}
public override fun onCleared() {
stopPreview()
super.onCleared()
}
private fun stopPreview() {
previewing = null
preview.stop()
}
private suspend fun setDefaultRingtone(kind: DefaultRingtoneKind, uri: String?) = when (kind) {
DefaultRingtoneKind.ALARM -> settings.setAlarmRingtoneUri(uri)
DefaultRingtoneKind.TIMER -> settings.setTimerRingtoneUri(uri)
}
private fun ClockDefaults.storedUriFor(kind: DefaultRingtoneKind) =
when (kind) {
DefaultRingtoneKind.ALARM -> alarmRingtoneUri
DefaultRingtoneKind.TIMER -> timerRingtoneUri
}
// --- the home time-zone picker ---
fun onOpenHomeZonePicker() {
zoneQuery.value = ""
zonePickerOpen.value = true
}
fun onZoneQueryChanged(query: String) {
zoneQuery.value = query
}
fun onDismissZonePicker() {
zonePickerOpen.value = false
zoneQuery.value = ""
}
/** A blank or non-IANA id writes nothing and does not throw. */
fun onHomeZonePicked(zoneId: String) {
val normalised = Zones.normalise(zoneId)
onDismissZonePicker()
if (normalised != null) viewModelScope.launch { settings.setHomeZoneId(normalised) }
}
/** Clears `home_zone_id`, so home follows the device again. */
fun onHomeDeviceZonePicked() {
onDismissZonePicker()
viewModelScope.launch { settings.setHomeZoneId(null) }
}
private companion object {
const val SUBSCRIPTION_GRACE_MS = 5_000L
}
}
@@ -35,6 +35,10 @@ import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
import de.jeanlucmakiola.clockula.ui.settings.BackupScreen
import de.jeanlucmakiola.clockula.ui.settings.SelfCheckScreen
import de.jeanlucmakiola.clockula.ui.settings.SettingsRoutes
import de.jeanlucmakiola.clockula.ui.settings.SettingsScreen
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
@@ -157,6 +161,7 @@ fun ClockulaShell(
AlarmsScreen(
onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) },
onCreateAlarm = { navController.navigate(AlarmRoutes.newEditor(it)) },
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
dismissCandidates = dismissCandidates,
onDismissCandidatesConsumed = { dismissCandidates = LongArray(0) },
)
@@ -183,6 +188,7 @@ fun ClockulaShell(
TimersScreen(
onEditTimer = { navController.navigate(TimerRoutes.editor(it)) },
onAddTimer = { navController.navigate(TimerRoutes.NEW) },
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
// Sibling routes in the same flat host, exactly as the
@@ -199,8 +205,32 @@ fun ClockulaShell(
composable(TimerRoutes.NEW) {
TimerSetupScreen(onBack = { navController.popBackStack() })
}
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
composable(ClockulaDestination.STOPWATCH.route) {
StopwatchScreen(
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
composable(ClockulaDestination.WORLD_CLOCK.route) {
WorldClockScreen(
onOpenSettings = { navController.navigate(SettingsRoutes.MAIN) },
)
}
// Sibling routes, reachable from any tab via the gear — no
// single tab owns Settings, which is why ShellNavigation
// deliberately maps none of these to a parent destination.
composable(SettingsRoutes.MAIN) {
SettingsScreen(
onBack = { navController.popBackStack() },
onOpenSelfCheck = { navController.navigate(SettingsRoutes.SELF_CHECK) },
onOpenBackup = { navController.navigate(SettingsRoutes.BACKUP) },
)
}
composable(SettingsRoutes.SELF_CHECK) {
SelfCheckScreen(onBack = { navController.popBackStack() })
}
composable(SettingsRoutes.BACKUP) {
BackupScreen(onBack = { navController.popBackStack() })
}
}
}
// In the pill's own band above the navigation component, not in the
@@ -47,7 +47,15 @@ object ShellNavigation {
/** Exact match only: a nested route is not a tab. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
/** Keep the parent tab highlighted while its editor — or setup page — is open. */
/**
* Keep the parent tab highlighted while its editor — or setup page — is open.
*
* Settings (M10) deliberately has **no** entry here: it opens from a gear on
* every tab, not from one owning tab, so there is no single parent to
* highlight. It falls through to [destinationOf], which returns null for it,
* and the caller's `selected ?: start` then leaves Alarms highlighted
* underneath — the same fallback an unrecognised route already gets.
*/
fun selectedDestinationOf(route: String?): ClockulaDestination? = when (route) {
AlarmRoutes.EDITOR_PATTERN -> ClockulaDestination.ALARMS
TimerRoutes.EDITOR_PATTERN, TimerRoutes.NEW -> ClockulaDestination.TIMERS
@@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -38,6 +42,7 @@ import kotlin.time.Duration
*/
@Composable
fun StopwatchScreen(
onOpenSettings: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: StopwatchViewModel = hiltViewModel(),
) {
@@ -57,6 +62,14 @@ fun StopwatchScreen(
title = stringResource(R.string.tab_stopwatch),
modifier = modifier,
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -12,8 +12,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -46,6 +48,7 @@ import de.jeanlucmakiola.floret.identity.animateItemMotion
fun TimersScreen(
onEditTimer: (Long) -> Unit,
onAddTimer: () -> Unit,
onOpenSettings: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: TimersViewModel = hiltViewModel(),
) {
@@ -60,6 +63,14 @@ fun TimersScreen(
title = stringResource(R.string.tab_timers),
modifier = modifier,
scrollable = false,
actions = {
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
// Only over a list: on an empty tab the keypad is already on
// screen, and a FAB that opens what is in front of you is noise.
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -56,6 +57,7 @@ import java.time.LocalTime
*/
@Composable
fun WorldClockScreen(
onOpenSettings: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: WorldClockViewModel = hiltViewModel(),
) {
@@ -82,6 +84,12 @@ fun WorldClockScreen(
contentDescription = stringResource(R.string.world_clock_home_action),
)
}
IconButton(onClick = onOpenSettings) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
)
}
},
floatingActionButton = {
val canAdd = loaded?.canAdd != false
+38
View File
@@ -242,4 +242,42 @@
<string name="interop_dismiss_title">Which alarm?</string>
<!-- %1$s is the time, followed by the label when the alarm has one. -->
<string name="interop_dismiss_row_description">Dismiss the alarm at %1$s</string>
<!-- M10: settings -->
<string name="settings_title">Settings</string>
<string name="settings_coming_soon">Coming soon</string>
<string name="settings_author" translatable="false">Jean-Luc Makiola</string>
<string name="settings_source">Source</string>
<string name="settings_source_url" translatable="false">https://codeberg.org/jlmakiola/clockula</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_theme">Theme</string>
<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_dynamic_color">Dynamic colour</string>
<string name="settings_language">Language</string>
<string name="settings_language_auto">Follow system</string>
<string name="settings_section_alarm_defaults">Alarm defaults</string>
<string name="settings_section_timer_defaults">Timer defaults</string>
<string name="settings_timer_duration">Default duration</string>
<plurals name="settings_duration_minutes">
<item quantity="one">%1$d minute</item>
<item quantity="other">%1$d minutes</item>
</plurals>
<string name="settings_section_clock">Clock</string>
<string name="settings_home_zone">Home time zone</string>
<string name="settings_section_reliability">Reliability</string>
<string name="settings_self_check">Why might my alarm not ring?</string>
<string name="settings_self_check_title">Why might my alarm not ring?</string>
<string name="settings_self_check_summary">Check exact-alarm, notification and battery settings</string>
<string name="settings_section_data">Data</string>
<string name="settings_backup">Back up &amp; restore</string>
<string name="settings_backup_title">Back up &amp; restore</string>
<string name="settings_backup_summary">Export or import your alarms, timers and world clocks</string>
<string name="settings_section_help">Help</string>
<string name="settings_report_issue">Report an issue</string>
<string name="settings_send_crash_report">Send last crash report</string>
<string name="settings_version">Version</string>
<string name="settings_version_value" translatable="false">%1$s</string>
<string name="settings_ringtone_custom">Custom sound</string>
</resources>
@@ -0,0 +1,176 @@
package de.jeanlucmakiola.clockula.ui.settings
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.worldClockHarness
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
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 kotlin.time.Duration.Companion.minutes
/**
* The real [SettingsViewModel] over the real [de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs]
* (a real DataStore under a `@TempDir`, exactly as [de.jeanlucmakiola.clockula.data.prefs.SettingsPrefsTest]
* uses it) and the real [de.jeanlucmakiola.clockula.ui.worldclock.WorldClockSource] from
* [de.jeanlucmakiola.clockula.testing.WorldClockHarness] — there is no engine to
* go through here either (M10, mirrors M8's `WorldClockViewModelTest`).
*/
class SettingsViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private fun TestScope.newViewModel(tempDir: Path): SettingsViewModel {
val harness = worldClockHarness(tempDir)
return SettingsViewModel(
settings = harness.settings,
ringtones = FakeRingtoneCatalog(),
preview = FakeRingtonePreviewer(),
worldClocks = harness.source,
)
}
private fun TestScope.collecting(viewModel: SettingsViewModel): SettingsViewModel =
viewModel.also { backgroundScope.launch { it.state.collect {} } }
@Test
fun `a fresh install loads the family defaults`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
// A standard dispatcher, so the initial Loading value is observable
// before the collector's first resumption — the unconfined default
// would otherwise race straight past it (WorldClockViewModelTest's
// first test makes the same swap for the same reason).
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = collecting(newViewModel(tempDir))
assertThat(viewModel.state.value).isEqualTo(SettingsUiState.Loading)
advanceUntilIdle()
val loaded = viewModel.state.value as SettingsUiState.Loaded
assertThat(loaded.appearance.themeMode).isEqualTo(ThemeMode.SYSTEM)
assertThat(loaded.defaults.snoozeMinutes).isEqualTo(10)
}
@Test
fun `the theme mode writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onThemeModeSelected(ThemeMode.DARK)
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).appearance.themeMode)
.isEqualTo(ThemeMode.DARK)
}
@Test
fun `dynamic colour writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onDynamicColorChanged(false)
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).appearance.dynamicColor).isFalse()
}
@Test
fun `every clock default setter writes through`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onVibrateChanged(false)
viewModel.onSnoozeMinutesChanged(7)
viewModel.onSnoozeLimitChanged(2)
viewModel.onVolumeRampChanged(30)
viewModel.onDismissChallengeChanged(DismissChallenge.MATH)
viewModel.onTimerDurationChanged(20.minutes)
advanceUntilIdle()
val defaults = (viewModel.state.value as SettingsUiState.Loaded).defaults
assertThat(defaults.vibrate).isFalse()
assertThat(defaults.snoozeMinutes).isEqualTo(7)
assertThat(defaults.snoozeLimit).isEqualTo(2)
assertThat(defaults.volumeRampSeconds).isEqualTo(30)
assertThat(defaults.dismissChallenge).isEqualTo(DismissChallenge.MATH)
assertThat(defaults.timerDuration).isEqualTo(20.minutes)
}
@Test
fun `choosing a default alarm ringtone writes through and closes the picker`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onOpenRingtonePicker(DefaultRingtoneKind.ALARM)
advanceUntilIdle()
assertThat(viewModel.ringtonePicker.value?.first).isEqualTo(DefaultRingtoneKind.ALARM)
viewModel.onRingtoneChosen("content://media/2")
advanceUntilIdle()
assertThat(viewModel.ringtonePicker.value).isNull()
assertThat((viewModel.state.value as SettingsUiState.Loaded).defaults.alarmRingtoneUri)
.isEqualTo("content://media/2")
}
@Test
fun `choosing a default timer ringtone does not touch the alarm default`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER)
advanceUntilIdle()
viewModel.onRingtoneChosen("content://media/3")
advanceUntilIdle()
val defaults = (viewModel.state.value as SettingsUiState.Loaded).defaults
assertThat(defaults.timerRingtoneUri).isEqualTo("content://media/3")
assertThat(defaults.alarmRingtoneUri).isNull()
}
@Test
fun `picking a home zone stores it`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onOpenHomeZonePicker()
advanceUntilIdle()
viewModel.onHomeZonePicked("Europe/Berlin")
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).defaults.homeZoneId)
.isEqualTo("Europe/Berlin")
assertThat(viewModel.zonePicker.value).isNull()
}
@Test
fun `picking the device zone clears the stored home zone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val viewModel = collecting(newViewModel(tempDir))
advanceUntilIdle()
viewModel.onOpenHomeZonePicker()
advanceUntilIdle()
viewModel.onHomeZonePicked("Europe/Berlin")
advanceUntilIdle()
viewModel.onOpenHomeZonePicker()
advanceUntilIdle()
viewModel.onHomeDeviceZonePicked()
advanceUntilIdle()
assertThat((viewModel.state.value as SettingsUiState.Loaded).defaults.homeZoneId).isNull()
}
}
@@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.stopwatch.StopwatchIntents
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
import de.jeanlucmakiola.clockula.ui.settings.SettingsRoutes
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
import org.junit.jupiter.api.Test
@@ -151,6 +152,15 @@ class ShellNavigationTest {
assertThat(ShellNavigation.destinationOf(TimerRoutes.editor(7L))).isNull()
}
// --- M10: settings has no single owning tab ---
@Test
fun `settings has no owning tab, so the caller falls back to the start destination`() {
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.MAIN)).isNull()
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.SELF_CHECK)).isNull()
assertThat(ShellNavigation.selectedDestinationOf(SettingsRoutes.BACKUP)).isNull()
}
@Test
fun `editors highlight their parent tab`() {
assertThat(ShellNavigation.selectedDestinationOf(AlarmRoutes.EDITOR_PATTERN))