refactor(alarms): point the editor at the moved ringtone picker

Completes the move: the old ui/alarms copies are gone and the editor imports
the shared one. Timers pick a sound the same way alarms do.
This commit is contained in:
2026-09-12 16:48:48 +02:00
parent b67142a727
commit fac9250ec7
4 changed files with 3 additions and 237 deletions
@@ -28,6 +28,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerScreen
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
import de.jeanlucmakiola.floret.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,184 +0,0 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.domain.Ringtones
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
* The alarm sound picker: App default, Silent, the user's own file when the
* device does not list it, and the device's own alarm tones — over a
* `LazyColumn`, because a device's tone list can be hundreds of rows (M5 D17).
*
* "Choose from files…" is an action row rather than an option, so the option
* list stays a list of selectable states. Each unselected row can be
* auditioned through the play button; taking a row stops the preview and
* closes the picker (D21).
*
* The SAF launcher is **not** registered here: this screen only exists while
* the ViewModel says the picker is open, and that flag does not survive process
* death — a result restored into a fresh process would have nowhere to land.
* [onPickFile] comes from the host, which registers it unconditionally (D20).
*/
@Composable
internal fun RingtonePickerScreen(
state: RingtonePickerUiState,
defaultUri: String?,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
onPickFile: () -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.ringtone_picker_title),
onDismiss = onDismiss,
scrollable = false,
) {
val rows = state.options.size + 1
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (state.deviceHasNoSounds) {
item {
Text(
text = stringResource(R.string.ringtone_none_available),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = GroupedListInset,
vertical = 8.dp,
),
)
}
}
if (state.loading) {
item {
CircularProgressIndicator(
modifier = Modifier.padding(GroupedListInset),
)
}
}
ringtoneOptions(state, rows, onChoose, onPreview, defaultUri)
item {
GroupedRow(
title = stringResource(R.string.ringtone_pick_file),
position = positionOf(rows - 1, rows),
onClick = onPickFile,
)
}
}
}
}
private fun LazyListScope.ringtoneOptions(
state: RingtonePickerUiState,
rows: Int,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
defaultUri: String?,
) {
state.options.forEachIndexed { index, option ->
item(key = optionKey(option)) {
RingtoneOptionRow(
option = option,
position = positionOf(index, rows),
selected = RingtonePicker.isSelected(option, state.selectedUri),
defaultUri = defaultUri,
defaultTitle = state.defaultTitle,
onChoose = onChoose,
onPreview = onPreview,
)
}
}
}
private fun optionKey(option: RingtoneChoice): String = when (option) {
RingtoneChoice.AppDefault -> "app-default"
RingtoneChoice.Silent -> "silent"
is RingtoneChoice.Sound -> option.uri
}
@Composable
private fun RingtoneOptionRow(
option: RingtoneChoice,
position: Position,
selected: Boolean,
defaultUri: String?,
defaultTitle: String?,
onChoose: (String?) -> Unit,
onPreview: (String?) -> Unit,
) {
val unknown = stringResource(R.string.ringtone_unknown)
val deviceDefault = stringResource(R.string.ringtone_device_default)
val silentTitle = stringResource(R.string.ringtone_silent)
val title = when (option) {
RingtoneChoice.AppDefault -> stringResource(R.string.override_app_default)
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent)
is RingtoneChoice.Sound -> option.title.ifBlank { unknown }
}
val summary = when (option) {
// What "App default" currently resolves to, so inheriting is not blind.
// The title is the *resolved* one, not a scan of the device's tone list:
// a SAF-picked default is absent from that list, and the row would then
// claim the device default instead (M5 D16).
RingtoneChoice.AppDefault -> when {
defaultUri == null -> deviceDefault
Ringtones.isSilent(defaultUri) -> silentTitle
else -> defaultTitle ?: unknown
}
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent_summary)
is RingtoneChoice.Sound -> null
}
val previewUri = when (option) {
RingtoneChoice.AppDefault -> defaultUri
RingtoneChoice.Silent -> null
is RingtoneChoice.Sound -> option.uri
}
val chosenUri = when (option) {
RingtoneChoice.AppDefault -> null
RingtoneChoice.Silent -> Ringtones.SILENT_URI
is RingtoneChoice.Sound -> option.uri
}
GroupedRow(
title = title,
summary = summary,
position = position,
selected = selected,
trailing = when {
selected -> {
{ SelectedCheck() }
}
previewUri != null -> {
{
IconButton(onClick = { onPreview(previewUri) }) {
Icon(Icons.Rounded.PlayArrow, contentDescription = title)
}
}
}
else -> null
},
onClick = { onChoose(chosenUri) },
)
}
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.clockula.ui.alarms
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import de.jeanlucmakiola.clockula.domain.Ringtones
sealed interface RingtoneChoice {
data object AppDefault : RingtoneChoice
data object Silent : RingtoneChoice
data class Sound(val uri: String, val title: String) : RingtoneChoice
}
data class RingtonePickerUiState(
val loading: Boolean,
val options: List<RingtoneChoice>,
/** The alarm's stored override: null = App default, sentinel = Silent. */
val selectedUri: String?,
/** The device reported no alarm sounds at all (M5 D19). */
val deviceHasNoSounds: Boolean,
/**
* The app default's own resolved title, or null when nothing named it — so
* the "App default" row can say what inheriting means even when the default
* is a SAF-picked file the device's tone list does not contain (M5 D16).
*/
val defaultTitle: String?,
)
internal object RingtonePicker {
/** Pure. App default, Silent, the stored sound when absent from [deviceTones], then [deviceTones]. */
fun optionsFor(
deviceTones: List<RingtoneOption>,
storedUri: String?,
storedTitle: String?,
): List<RingtoneChoice> {
val stored = Ringtones.normalise(storedUri)
// The user's own sound sits directly under Silent when the device does
// not list it — a SAF-picked file would otherwise be invisible, and so
// uncheckable, in the picker that chose it (M5 D17).
val own = stored
?.takeUnless { Ringtones.isSilent(it) }
?.takeUnless { uri -> deviceTones.any { it.uri == uri } }
?.let { RingtoneChoice.Sound(it, storedTitle.orEmpty()) }
val sounds = (listOfNotNull(own) + deviceTones.map { RingtoneChoice.Sound(it.uri, it.title) })
.distinctBy { it.uri }
return listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent) + sounds
}
fun isSelected(option: RingtoneChoice, storedUri: String?): Boolean = when (option) {
RingtoneChoice.AppDefault -> storedUri == null
RingtoneChoice.Silent -> Ringtones.isSilent(storedUri)
is RingtoneChoice.Sound -> option.uri == storedUri
}
}