diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SleepTimerSlider.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SleepTimerSlider.kt index 9da8316e..7158c4c4 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SleepTimerSlider.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SleepTimerSlider.kt @@ -37,9 +37,10 @@ fun SleepTimerSlider( onUpdate: (TimerOption?) -> Unit, ) { val sliderRange = INTERNAL_MIN_VALUE..INTERNAL_MAX_VALUE + val floatRange = sliderRange.first.toFloat()..sliderRange.last.toFloat() val onValueUpdate: (Float) -> Unit = { value -> - onUpdate(value.toTimerOption()) + onUpdate(value.coerceIn(floatRange).toTimerOption()) } val sliderState = @@ -56,15 +57,17 @@ fun SleepTimerSlider( } LaunchedEffect(option) { - sliderState.animateDecayTo(option.toInternalValue().toFloat()) + sliderState.animateDecayTo(option.toInternalValue().toFloat().coerceIn(floatRange)) } + val clampedCurrent = sliderState.current.coerceIn(floatRange) + Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = sliderState.current.toLabelText(libraryType, context), + text = clampedCurrent.toLabelText(libraryType, context), style = typography.headlineSmall, ) @@ -85,12 +88,12 @@ fun SleepTimerSlider( val visibleSegmentCount = (visibleSegments + 1) / 2 val minIndex = - (sliderState.current - visibleSegmentCount) + (clampedCurrent - visibleSegmentCount) .roundToInt() .coerceAtLeast(sliderRange.first) val maxIndex = - (sliderState.current + visibleSegmentCount) + (clampedCurrent + visibleSegmentCount) .roundToInt() .coerceAtMost(sliderRange.last) @@ -99,7 +102,7 @@ fun SleepTimerSlider( for (index in minIndex..maxIndex) { SpeedSliderSegment( index = index, - currentValue = sliderState.current, + currentValue = clampedCurrent, segmentWidth = segmentWidth, segmentPixelWidth = segmentPixelWidth, centerPixel = centerPixel, @@ -120,7 +123,8 @@ private fun TimerOption?.toInternalValue(): Int = } private fun Float.toTimerOption(): TimerOption? { - val value = roundToInt() + val value = roundToInt().coerceIn(INTERNAL_MIN_VALUE, INTERNAL_MAX_VALUE) + return when (value) { INTERNAL_DISABLED -> null INTERNAL_CHAPTER_END -> CurrentEpisodeTimerOption @@ -132,7 +136,8 @@ private fun Float.toLabelText( libraryType: LibraryType, context: Context, ): String { - val value = roundToInt() + val value = roundToInt().coerceIn(INTERNAL_MIN_VALUE, INTERNAL_MAX_VALUE) + return when (value) { INTERNAL_DISABLED -> context.getString(R.string.timer_option_disabled) INTERNAL_CHAPTER_END -> diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SliderState.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SliderState.kt index 4cdba40f..5f446f0c 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SliderState.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/components/slider/SliderState.kt @@ -48,11 +48,11 @@ class SliderState( } companion object { - private const val maxSpeed = 25f + private const val maxSpeed = 10f private val springSpec = FloatSpringSpec( - dampingRatio = Spring.DampingRatioMediumBouncy, + dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow, ) diff --git a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt index 2258ba6d..0cb4f159 100644 --- a/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt +++ b/app/src/main/kotlin/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt @@ -1,20 +1,41 @@ package org.grakovne.lissen.ui.screens.player.composable +import android.view.View +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.grakovne.lissen.R +import org.grakovne.lissen.common.withHaptic +import org.grakovne.lissen.lib.domain.DurationTimerOption import org.grakovne.lissen.lib.domain.LibraryType import org.grakovne.lissen.lib.domain.TimerOption import org.grakovne.lissen.ui.components.slider.SleepTimerSlider @@ -27,7 +48,9 @@ fun TimerComposable( onOptionSelected: (TimerOption?) -> Unit, onDismissRequest: () -> Unit, ) { + val view = LocalView.current val context = LocalContext.current + var selectedOption by remember { mutableStateOf(currentOption) } ModalBottomSheet( containerColor = colorScheme.background, @@ -56,7 +79,73 @@ fun TimerComposable( .padding(vertical = 16.dp), onUpdate = { onOptionSelected(it) }, ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + OptionPresets.forEach { value -> + FilledTonalButton( + onClick = { + withHaptic(view) { + selectedOption = value + onOptionSelected(value) + } + }, + modifier = Modifier.size(56.dp), + shape = CircleShape, + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = + if (selectedOption == value) { + colorScheme.primary + } else { + colorScheme.surfaceContainer + }, + contentColor = + if (selectedOption == value) { + colorScheme.onPrimary + } else { + colorScheme.onSurfaceVariant + }, + ), + contentPadding = PaddingValues(0.dp), + ) { + if (value == null) { + val fontSize = typography.labelMedium.fontSize + val iconSize = with(LocalDensity.current) { fontSize.toDp() } * 1.5f + + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = null, + modifier = Modifier.size(iconSize), + ) + } else { + Text( + text = value.duration.toString(), + style = + if (selectedOption == value) { + typography.labelMedium.copy(fontWeight = FontWeight.Bold) + } else { + typography.labelMedium + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } } }, ) } + +private val OptionPresets = + listOf( + null, + DurationTimerOption(10), + DurationTimerOption(15), + DurationTimerOption(30), + DurationTimerOption(60), + )