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.
This commit is contained in:
2026-10-02 19:16:32 +02:00
parent 520bf379b4
commit 7f2b047182
8 changed files with 744 additions and 303 deletions
@@ -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<OpenAlarmPicker?>(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 },
)
}
}
@@ -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
},
)
@@ -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,
)
}
}
@@ -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) }
}
@@ -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<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) }
var section by rememberSaveable { mutableStateOf<SettingsSection?>(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<String?>(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)
}
@@ -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)
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Codeberg brand mark, used on the "Source" row in Settings → About. The repo
is hosted on Codeberg, so this is the forge the row actually opens — it
replaces the Gitea mark Calendula's hub was copied from.
Single-path logo from Simple Icons (https://simpleicons.org, CC0), pathData
otherwise kept verbatim so Android's PathParser reads the arc flags; the one
edit is upstream's duplicated trailing "z", a closepath on an already-closed
subpath that no parser needs to see twice.
fillColor is a placeholder; the Compose Icon recolours it via tint.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z" />
</vector>
+28 -13
View File
@@ -245,28 +245,47 @@
<!-- 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_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_logo_desc">Clockula app icon</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_source">Source</string>
<string name="settings_license">License</string>
<string name="settings_privacy">Privacy policy</string>
<string name="settings_about_version">Version %1$s</string>
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/clockula</string>
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/clockula/src/branch/main/LICENSE</string>
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/clockula/privacy</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="settings_group_look">Look &amp; behaviour</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">About</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</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_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_dynamic_color_unavailable">Needs Android 12 or newer</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_section_alarms">Alarms</string>
<string name="settings_alarms_subtitle">Sound, snooze and dismiss for new alarms</string>
<string name="settings_alarm_defaults_hint">New alarms start with these. Each alarm can still change them in its editor.</string>
<string name="settings_section_timers">Timers</string>
<string name="settings_timers_subtitle">Sound and length for new timers</string>
<string name="settings_timer_defaults_hint">New timers start with these.</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_clock_subtitle">Home time zone</string>
<string name="settings_clock_hint">World clocks show how far each city is from your home time zone.</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>
@@ -275,15 +294,11 @@
<item quantity="other">%1$d problems found</item>
</plurals>
<string name="settings_self_check_all_clear">Everything looks fine</string>
<string name="settings_section_data">Data</string>
<string name="settings_backup">Back up &amp; restore</string>
<string name="settings_backup_title">Back up &amp; restore</string>
<string name="settings_backup_summary">Export or import your alarms, timers and world clocks</string>
<string name="settings_section_help">Help</string>
<string name="settings_report_issue">Report an issue</string>
<string name="settings_send_crash_report">Send last crash report</string>
<string name="settings_version">Version</string>
<string name="settings_version_value" translatable="false">%1$s</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Open the issue tracker</string>
<string name="settings_ringtone_custom">Custom sound</string>
<!-- M10: backup -->