From 7f2b047182a9b7894390db88e67d04a52663baa4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 2 Oct 2026 19:16:32 +0200 Subject: [PATCH] feat(settings): category hub with sub-screens, matching Agendula and Calendula The flat settings list becomes a hub of icon-chip category rows that slide into their own screens: Appearance, Alarms, Timers and Clock. The About card carries the app logo and a support row; source, licence and privacy links move to an About group at the foot, above a plain version line. Report a problem now surfaces a pending crash report first, replacing the separate "Send last crash report" row. --- .../ui/settings/AlarmDefaultsScreen.kt | 128 ++++ .../clockula/ui/settings/AppearanceScreen.kt | 77 +++ .../ui/settings/ClockSettingsScreen.kt | 32 + .../clockula/ui/settings/SettingsCommon.kt | 135 +++++ .../clockula/ui/settings/SettingsScreen.kt | 549 +++++++++--------- .../ui/settings/TimerDefaultsScreen.kt | 65 +++ app/src/main/res/drawable/ic_codeberg.xml | 20 + app/src/main/res/values/strings.xml | 41 +- 8 files changed, 744 insertions(+), 303 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AppearanceScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsCommon.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt create mode 100644 app/src/main/res/drawable/ic_codeberg.xml diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt new file mode 100644 index 0000000..4e4075d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt @@ -0,0 +1,128 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +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.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position + +/** 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 } + +/** + * What every new alarm starts with: how it sounds, how it snoozes, and how it + * is dismissed. The ringtone picker itself is ViewModel state, drawn by the hub. + */ +@Composable +internal fun AlarmDefaultsScreen( + state: SettingsUiState.Loaded, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val defaults = state.defaults + var openPicker by rememberSaveable { mutableStateOf(null) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_alarms), onBack = onBack) { + SettingsHint(stringResource(R.string.settings_alarm_defaults_hint)) + Spacer(Modifier.height(8.dp)) + + GroupedRow( + title = stringResource(R.string.editor_ringtone), + summary = ringtoneSummary(defaults.alarmRingtoneUri), + position = Position.Top, + onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.ALARM) }, + ) + GroupedRow( + title = stringResource(R.string.editor_vibrate), + summary = vibrateText(defaults.vibrate), + position = Position.Middle, + onClick = { openPicker = OpenAlarmPicker.VIBRATE }, + ) + GroupedRow( + title = stringResource(R.string.editor_volume_ramp), + summary = volumeRampText(defaults.volumeRampSeconds), + position = Position.Bottom, + onClick = { openPicker = OpenAlarmPicker.VOLUME_RAMP }, + ) + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.editor_snooze_length), + summary = snoozeMinutesText(defaults.snoozeMinutes), + position = Position.Top, + onClick = { openPicker = OpenAlarmPicker.SNOOZE_LENGTH }, + ) + GroupedRow( + title = stringResource(R.string.editor_snooze_limit), + summary = snoozeLimitText(defaults.snoozeLimit), + position = Position.Bottom, + onClick = { openPicker = OpenAlarmPicker.SNOOZE_LIMIT }, + ) + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.editor_dismiss_challenge), + summary = challengeText(defaults.dismissChallenge), + position = Position.Alone, + onClick = { openPicker = OpenAlarmPicker.CHALLENGE }, + ) + } + + when (openPicker) { + null -> Unit + OpenAlarmPicker.VIBRATE -> VibratePicker( + stored = defaults.vibrate, + default = defaults.vibrate, + includeAppDefault = false, + onSelect = { viewModel.onVibrateChanged(it ?: defaults.vibrate) }, + onDismiss = { openPicker = null }, + ) + OpenAlarmPicker.VOLUME_RAMP -> VolumeRampPicker( + stored = defaults.volumeRampSeconds, + default = defaults.volumeRampSeconds, + includeAppDefault = false, + onSelect = { viewModel.onVolumeRampChanged(it ?: defaults.volumeRampSeconds) }, + onDismiss = { openPicker = null }, + ) + OpenAlarmPicker.SNOOZE_LENGTH -> SnoozeLengthPicker( + stored = defaults.snoozeMinutes, + default = defaults.snoozeMinutes, + includeAppDefault = false, + onSelect = { viewModel.onSnoozeMinutesChanged(it ?: defaults.snoozeMinutes) }, + onDismiss = { openPicker = null }, + ) + OpenAlarmPicker.SNOOZE_LIMIT -> SnoozeLimitPicker( + stored = defaults.snoozeLimit, + default = defaults.snoozeLimit, + includeAppDefault = false, + onSelect = { viewModel.onSnoozeLimitChanged(it ?: defaults.snoozeLimit) }, + onDismiss = { openPicker = null }, + ) + OpenAlarmPicker.CHALLENGE -> DismissChallengePicker( + stored = defaults.dismissChallenge, + default = defaults.dismissChallenge, + includeAppDefault = false, + onSelect = { viewModel.onDismissChallengeChanged(it ?: defaults.dismissChallenge) }, + onDismiss = { openPicker = null }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AppearanceScreen.kt new file mode 100644 index 0000000..87e601c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AppearanceScreen.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import android.os.Build +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.prefs.ThemeMode + +/** How the app looks: the theme and wallpaper colours. */ +@Composable +internal fun AppearanceScreen( + state: SettingsUiState.Loaded, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + var showTheme by rememberSaveable { mutableStateOf(false) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_theme), + summary = themeModeLabel(state.appearance.themeMode), + position = Position.Top, + onClick = { showTheme = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_dynamic_color), + summary = if (dynamicColorAvailable) { + stringResource(R.string.settings_dynamic_color_hint) + } else { + stringResource(R.string.settings_dynamic_color_unavailable) + }, + position = Position.Bottom, + trailing = { + Switch( + checked = state.appearance.dynamicColor && dynamicColorAvailable, + onCheckedChange = viewModel::onDynamicColorChanged, + enabled = dynamicColorAvailable, + ) + }, + onClick = if (dynamicColorAvailable) { + { viewModel.onDynamicColorChanged(!state.appearance.dynamicColor) } + } else { + null + }, + ) + } + + if (showTheme) { + OptionPicker( + title = stringResource(R.string.settings_theme), + options = ThemeMode.entries.toList(), + selected = state.appearance.themeMode, + label = { themeModeLabel(it) }, + onSelect = viewModel::onThemeModeSelected, + onDismiss = { showTheme = false }, + ) + } +} + +@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 + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt new file mode 100644 index 0000000..d669685 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/ClockSettingsScreen.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position + +/** The clock tab's settings. The zone picker itself is ViewModel state, drawn by the hub. */ +@Composable +internal fun ClockSettingsScreen( + state: SettingsUiState.Loaded, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + CollapsingScaffold(title = stringResource(R.string.settings_section_clock), onBack = onBack) { + SettingsHint(stringResource(R.string.settings_clock_hint)) + Spacer(Modifier.height(8.dp)) + + GroupedRow( + title = stringResource(R.string.settings_home_zone), + summary = state.defaults.homeZoneId ?: stringResource(R.string.zone_picker_device_zone), + position = Position.Alone, + onClick = viewModel::onOpenHomeZonePicker, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsCommon.kt new file mode 100644 index 0000000..de60a57 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsCommon.kt @@ -0,0 +1,135 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.floret.components.GroupedListInset + +/** + * Pieces shared by the settings hub and its sub-screens, after Agendula's and + * Calendula's `SettingsCommon.kt`. Each sub-screen owns whatever only it uses. + */ + +/** + * Accent for a leading icon chip. The chips are a scanning aid, so no two rows + * in one group share an accent; [Neutral] is a step back for reference rows + * rather than a fourth colour to rotate through. + */ +internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary } + +/** Leading circular icon chip, coloured by an M3 container/on-container pair. */ +@Composable +internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { + val scheme = MaterialTheme.colorScheme + val (background, iconColor) = when (accent) { + ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant + ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer + ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer + ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer + } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(22.dp), + ) + } +} + +/** Muted supporting text at the head of a sub-screen. */ +@Composable +internal fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) +} + +/** + * The app icon as a rounded chip: the launcher mark over its background colour, + * oversized and clipped the way a launcher mask would. Sized just past the two + * text lines beside it; [MARK_OVERSCAN] holds the crop as it shrinks. + */ +@Composable +internal fun AppLogo() { + Box( + modifier = Modifier + .size(LOGO_SIZE) + .clip(RoundedCornerShape(16.dp)) + .background(colorResource(R.color.ic_launcher_background)), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.settings_about_logo_desc), + modifier = Modifier.requiredSize(LOGO_SIZE * MARK_OVERSCAN), + ) + } +} + +/** Roughly the height of the app name and author lines it sits beside. */ +private val LOGO_SIZE = 56.dp + +/** + * How far the mark overruns its chip. An adaptive icon's foreground carries a + * wide safe margin, so at 1:1 it would sit small and lost; launchers crop it too. + */ +private const val MARK_OVERSCAN = 1.5f + +/** Plain centred version mark at the foot of the settings list (no card). */ +@Composable +internal fun AppVersionText() { + val context = LocalContext.current + val versionName = remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } + .getOrNull() ?: "—" + } + Text( + text = stringResource(R.string.settings_about_version, versionName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) +} + +internal fun openUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + runCatching { context.startActivity(intent) } +} 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 index c1e235c..c56b53b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsScreen.kt @@ -1,22 +1,32 @@ package de.jeanlucmakiola.clockula.ui.settings -import android.os.Build +import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding 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.material.icons.filled.Alarm +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,52 +34,44 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp 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.GroupedListInset 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 } +/** The settings sub-screens reached from the hub's category rows. */ +private enum class SettingsSection { Appearance, Alarms, Timers, Clock } 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). + * Settings, structured (after Calendula and Agendula) as a category hub with + * sliding sub-screens, each in its own file. Self-check and Backup are their + * own navigation destinations, opened through [onOpenSelfCheck] and + * [onOpenBackup]. Every control writes at once — there is no Save button here + * either (M5 D22). + * + * This file owns the hub, the section enum, and the two ViewModel-driven + * pickers (ringtone, home zone) that sub-screens open. */ @Composable fun SettingsScreen( @@ -82,13 +84,7 @@ fun SettingsScreen( 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) } + var section by rememberSaveable { mutableStateOf(null) } // Registered unconditionally, for the same reason the alarm editor's is // (M5 D20): the picker's open flag is ViewModel state that does not @@ -99,214 +95,36 @@ fun SettingsScreen( 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), - ), - ), + // Inside a sub-screen, system back (button or gesture) returns to the hub + // rather than popping the whole Settings destination. + BackHandler(enabled = section != null) { section = null } + + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + SettingsHub( + selfCheckProblemCount = loaded?.selfCheckProblemCount, + onBack = onBack, + onOpenSection = { section = it }, + onOpenSelfCheck = onOpenSelfCheck, + onOpenBackup = onOpenBackup, ) 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) }, - ) + SlideInSection(visible = section == SettingsSection.Appearance) { + AppearanceScreen(state = loaded, viewModel = viewModel, onBack = { section = null }) + } + SlideInSection(visible = section == SettingsSection.Alarms) { + AlarmDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }) + } + SlideInSection(visible = section == SettingsSection.Timers) { + TimerDefaultsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }) + } + SlideInSection(visible = section == SettingsSection.Clock) { + ClockSettingsScreen(state = loaded, viewModel = viewModel, onBack = { section = null }) } - 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 = selfCheckSummary(loaded?.selfCheckProblemCount), - 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 }, - ) } } @@ -331,37 +149,205 @@ fun SettingsScreen( 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 }, - ) - } +@Composable +private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally { it } + fadeIn(), + exit = slideOutHorizontally { it } + fadeOut(), + ) { content() } +} + +// --------------------------------------------------------------------------- +// Hub +// --------------------------------------------------------------------------- + +/** + * Four named groups of category rows, after Calendula's hub: look & behaviour, + * data, app, and a foot of reference links. Source, licence and privacy sit in that + * last group rather than as buttons inside the About card — they are reference + * material, and the card is the app's identity. + */ +@Composable +private fun SettingsHub( + selfCheckProblemCount: Int?, + onBack: () -> Unit, + onOpenSection: (SettingsSection) -> Unit, + onOpenSelfCheck: () -> Unit, + onOpenBackup: () -> Unit, +) { + CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) { + // Card and support row are one grouped block, so the call to action + // continues the container instead of sitting inside it as a button. + Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } + SupportRow() + Spacer(Modifier.height(16.dp)) + + // Each group cycles its chip accents so no two rows in it look alike + // (see [ChipAccent]). + GroupedSectionHeader(stringResource(R.string.settings_group_look)) + GroupedRow( + title = stringResource(R.string.settings_section_appearance), + summary = stringResource(R.string.settings_appearance_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Secondary) }, + onClick = { onOpenSection(SettingsSection.Appearance) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_alarms), + summary = stringResource(R.string.settings_alarms_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Alarm, ChipAccent.Primary) }, + onClick = { onOpenSection(SettingsSection.Alarms) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_timers), + summary = stringResource(R.string.settings_timers_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Timer, ChipAccent.Tertiary) }, + onClick = { onOpenSection(SettingsSection.Timers) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_clock), + summary = stringResource(R.string.settings_clock_subtitle), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Public, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Clock) }, + ) + + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_data)) + GroupedRow( + title = stringResource(R.string.settings_backup), + summary = stringResource(R.string.settings_backup_summary), + position = Position.Alone, + leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) }, + onClick = onOpenBackup, + ) + + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_app)) + GroupedRow( + title = stringResource(R.string.settings_self_check), + summary = selfCheckSummary(selfCheckProblemCount), + position = Position.Top, + leading = { CategoryIcon(Icons.Default.HealthAndSafety, ChipAccent.Tertiary) }, + onClick = onOpenSelfCheck, + ) + LanguagePickerRow( + position = Position.Middle, + title = stringResource(R.string.settings_language), + autoLabel = stringResource(R.string.settings_language_auto), + localesConfig = R.xml.locales_config, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, + ) + ReportProblemRow(position = Position.Bottom) + + // Source and licence sit at the bottom as reference material rather + // than inside the About card. + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_about)) + AboutLinkRow( + label = stringResource(R.string.settings_about_source), + url = stringResource(R.string.about_source_url), + icon = ImageVector.vectorResource(R.drawable.ic_codeberg), + position = Position.Top, + ) + AboutLinkRow( + label = stringResource(R.string.settings_license), + url = stringResource(R.string.about_license_url), + icon = Icons.Default.Gavel, + position = Position.Middle, + ) + AboutLinkRow( + label = stringResource(R.string.settings_privacy), + url = stringResource(R.string.about_privacy_url), + icon = Icons.Default.PrivacyTip, + position = Position.Bottom, + ) + + AppVersionText() } } -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 - }, -) +private fun AboutCard() { + // Layout lives in floret-kit (components.AboutCard); Clockula supplies its + // own logo and author. Support is the [SupportRow] joined below. + AboutCard( + logo = { AppLogo() }, + appName = stringResource(R.string.app_name), + author = stringResource(R.string.settings_about_author), + primaryLinks = emptyList(), + position = Position.Top, + ) +} + +/** "Support development", as the closing row of the About card's group. */ +@Composable +private fun SupportRow() { + val context = LocalContext.current + val url = stringResource(R.string.about_support_url) + GroupedRow( + title = stringResource(R.string.settings_about_support), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) }, + onClick = { openUrl(context, url) }, + ) +} + +/** + * One reference link at the foot of the hub (source, licence, privacy). The + * privacy policy has to stay reachable from inside the app, not only from the + * store listing. + */ +@Composable +private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) { + val context = LocalContext.current + GroupedRow( + title = label, + position = position, + leading = { CategoryIcon(icon, ChipAccent.Neutral) }, + onClick = { openUrl(context, url) }, + ) +} + +/** + * Opens the project's issue tracker to report a problem. If a crash report was + * captured (and not yet sent), it surfaces that report first via the same + * dialog the next-launch prompt uses; otherwise it opens the issue template + * chooser. No data leaves the device until the user submits the issue. + */ +@Composable +private fun ReportProblemRow(position: Position) { + val context = LocalContext.current + var report by remember { mutableStateOf(null) } + + GroupedRow( + title = stringResource(R.string.settings_report_problem), + summary = stringResource(R.string.settings_report_problem_hint), + position = position, + leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, + onClick = { + val pending = CrashReporter.pendingReport(context) + if (pending != null) report = pending else openIssueTracker(context) + }, + ) + + report?.let { pending -> + CrashReportDialog( + report = pending, + onSend = { + submitCrashReport(context, pending) + CrashReporter.clearReport(context) + report = null + }, + onDismiss = { report = null }, + ) + } +} /** Null while the one-off read is in flight; otherwise the plain-language count. */ @Composable @@ -372,25 +358,8 @@ private fun selfCheckSummary(problemCount: Int?): String = when { } @Composable -private fun ringtoneSummary(uri: String?): String = when { +internal 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/TimerDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt new file mode 100644 index 0000000..88c8938 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/TimerDefaultsScreen.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.clockula.ui.settings + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.timer.TimerPresets +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import kotlin.time.Duration + +/** What a new timer starts with: its sound and its length. */ +@Composable +internal fun TimerDefaultsScreen( + state: SettingsUiState.Loaded, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showDuration by rememberSaveable { mutableStateOf(false) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_timers), onBack = onBack) { + SettingsHint(stringResource(R.string.settings_timer_defaults_hint)) + Spacer(Modifier.height(8.dp)) + + GroupedRow( + title = stringResource(R.string.editor_ringtone), + summary = ringtoneSummary(state.defaults.timerRingtoneUri), + position = Position.Top, + onClick = { viewModel.onOpenRingtonePicker(DefaultRingtoneKind.TIMER) }, + ) + GroupedRow( + title = stringResource(R.string.settings_timer_duration), + summary = durationMinutesText(state.defaults.timerDuration), + position = Position.Bottom, + onClick = { showDuration = true }, + ) + } + + if (showDuration) { + OptionPicker( + title = stringResource(R.string.settings_timer_duration), + options = TimerPresets.BUILT_IN, + selected = state.defaults.timerDuration, + label = { durationMinutesText(it) }, + onSelect = viewModel::onTimerDurationChanged, + onDismiss = { showDuration = false }, + ) + } +} + +@Composable +private fun durationMinutesText(duration: Duration): String { + val minutes = duration.inWholeMinutes.toInt() + return pluralStringResource(R.plurals.settings_duration_minutes, minutes, minutes) +} diff --git a/app/src/main/res/drawable/ic_codeberg.xml b/app/src/main/res/drawable/ic_codeberg.xml new file mode 100644 index 0000000..8f5bfcf --- /dev/null +++ b/app/src/main/res/drawable/ic_codeberg.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0e3de9..3266d5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,28 +245,47 @@ Settings - Coming soon - Jean-Luc Makiola - Source - https://codeberg.org/jlmakiola/clockula + by Jean-Luc Makiola + Clockula app icon + Support development + Source + License + Privacy policy + Version %1$s + https://codeberg.org/jlmakiola/clockula + https://codeberg.org/jlmakiola/clockula/src/branch/main/LICENSE + https://jeanlucmakiola.de/clockula/privacy + https://ko-fi.com/jeanlucmakiola + Look & behaviour + Data + App + About Appearance + Theme and colour Theme Follow system Light Dark Dynamic colour + Use colours from your wallpaper + Needs Android 12 or newer Language Follow system - Alarm defaults - Timer defaults + Alarms + Sound, snooze and dismiss for new alarms + New alarms start with these. Each alarm can still change them in its editor. + Timers + Sound and length for new timers + New timers start with these. Default duration %1$d minute %1$d minutes Clock + Home time zone + World clocks show how far each city is from your home time zone. Home time zone - Reliability Why might my alarm not ring? Why might my alarm not ring? Check exact-alarm, notification and battery settings @@ -275,15 +294,11 @@ %1$d problems found Everything looks fine - 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 + Report a problem + Open the issue tracker Custom sound