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))