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:
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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_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 -->
|
||||
|
||||
Reference in New Issue
Block a user