From 7253445c4ea28cd27ec76b104676556e0d1184ae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 1 Oct 2026 17:36:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20a=20settings=20hub=20?= =?UTF-8?q?=E2=80=94=20appearance,=20alarm/timer/clock=20defaults,=20data,?= =?UTF-8?q?=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../jeanlucmakiola/clockula/MainActivity.kt | 4 +- .../clockula/ui/alarms/AlarmsScreen.kt | 11 + .../clockula/ui/alarms/OverridePickers.kt | 19 +- .../ui/common/RingtonePickerScreen.kt | 13 +- .../clockula/ui/settings/BackupScreen.kt | 24 ++ .../clockula/ui/settings/SelfCheckScreen.kt | 24 ++ .../clockula/ui/settings/SettingsRoutes.kt | 8 + .../clockula/ui/settings/SettingsScreen.kt | 388 ++++++++++++++++++ .../clockula/ui/settings/SettingsUiState.kt | 13 + .../clockula/ui/settings/SettingsViewModel.kt | 212 ++++++++++ .../clockula/ui/shell/ClockulaShell.kt | 34 +- .../clockula/ui/shell/ShellNavigation.kt | 10 +- .../clockula/ui/stopwatch/StopwatchScreen.kt | 13 + .../clockula/ui/timers/TimersScreen.kt | 11 + .../ui/worldclock/WorldClockScreen.kt | 8 + app/src/main/res/values/strings.xml | 38 ++ .../ui/settings/SettingsViewModelTest.kt | 176 ++++++++ .../clockula/ui/shell/ShellNavigationTest.kt | 10 + 18 files changed, 1008 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index e5c7816..958be6d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index 49f990f..37c6abc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt index d4f855a..4ba9867 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt @@ -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 OverridePicker( @@ -160,10 +176,11 @@ private fun 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) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt index a728681..49bf7a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/common/RingtonePickerScreen.kt @@ -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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt new file mode 100644 index 0000000..e996d56 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/BackupScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt new file mode 100644 index 0000000..9c93c4a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SelfCheckScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt new file mode 100644 index 0000000..ce364a0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsRoutes.kt @@ -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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..7af1425 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -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(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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt new file mode 100644 index 0000000..191937d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -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 } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..8c47e46 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -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 = combine( + settings.appearance, + settings.defaults, + ) { appearance, defaults -> + SettingsUiState.Loaded(appearance, defaults) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading) + + private val _ringtonePicker = MutableStateFlow?>(null) + + /** Null while closed; the kind being edited and its picker state while open. */ + internal val ringtonePicker: StateFlow?> = + _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 = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index c413474..3c9dddd 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt index 8dcb8bb..5c2e21d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt index 4338756..be83ee5 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt @@ -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(), diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt index 7119dc6..bb18dd2 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt index 7dd6945..4c54bbf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 510bb5c..fcc7e8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,4 +242,42 @@ Which alarm? Dismiss the alarm at %1$s + + + Settings + Coming soon + Jean-Luc Makiola + Source + https://codeberg.org/jlmakiola/clockula + Appearance + Theme + Follow system + Light + Dark + Dynamic colour + Language + Follow system + Alarm defaults + Timer defaults + Default duration + + %1$d minute + %1$d minutes + + Clock + Home time zone + Reliability + Why might my alarm not ring? + Why might my alarm not ring? + Check exact-alarm, notification and battery settings + Data + Back up & restore + Back up & restore + Export or import your alarms, timers and world clocks + Help + Report an issue + Send last crash report + Version + %1$s + Custom sound diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt new file mode 100644 index 0000000..20cdbb4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModelTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt index d992d0b..354fcc1 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -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))