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