diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt index f87c62e..1b40cea 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt index 5148f72..47f055a 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt deleted file mode 100644 index 6d509d4..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt +++ /dev/null @@ -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) }, - ) -} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt deleted file mode 100644 index e67158d..0000000 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt +++ /dev/null @@ -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, - /** 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, - storedUri: String?, - storedTitle: String?, - ): List { - 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 - } -}