package com.suno.android.ui.screens.create.text import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf 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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.SunoTheme import kotlinx.coroutines.delay import kotlin.math.abs import kotlin.math.roundToInt @Composable fun CustomSlider( label: String, value: Double, onValueChange: (Double) -> Unit, onDragEnd: ((Double) -> Unit)? = {}, onDoubleTap: (() -> Unit)? = {}, generalInfoTooltipText: String? = null, tooltipRanges: Map? = null, ) { // `value` is true value of the slider (i.e. 0f-1f) // `displayValue` is what is shown to the user (i.e. 0% - 100%) // `inputValue` is what is shown to the user in the text field (i.e. 0% - 100%) val density = LocalDensity.current val valueRange = 0..100 var dragPosition by remember { mutableFloatStateOf(value.toFloat()) } var sliderSize by remember { mutableStateOf(IntSize.Zero) } var displayValue by remember { mutableDoubleStateOf(value * 100) } var inputValue by remember { mutableDoubleStateOf(displayValue) } var isDragging by remember { mutableStateOf(false) } var displayGeneralTooltip by remember { mutableStateOf(false) } val steps = valueRange.last - valueRange.first val fixedLabelWidth = 124.dp // Convert the display value (0-100) to a position on the slider fun valueToPosition( value: Double, ): Float = ((value - valueRange.first).toFloat() / steps) * sliderSize.width // Convert the a position on the slider to a display value (0-100) fun positionToValue( position: Float, ): Double { val normalized = (position / sliderSize.width) * steps val discreteValue = normalized.roundToInt() + valueRange.first return discreteValue.coerceIn(valueRange).toDouble() } LaunchedEffect(value) { displayGeneralTooltip = false dragPosition = valueToPosition(value * 100) inputValue = value * 100 displayValue = value * 100 } LaunchedEffect(displayGeneralTooltip) { if (displayGeneralTooltip) { delay(2500) displayGeneralTooltip = false } } Box { // general tooltip AnimatedVisibility( visible = displayGeneralTooltip, enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), modifier = Modifier .offset( x = 0.dp, y = (-40).dp, ) .wrapContentSize() .zIndex(10f) .align(Alignment.CenterStart), ) { Text( modifier = Modifier .wrapContentSize() .background( color = ExtendedTheme.colors.backgroundQuarternary.copy(alpha = 0.9f), shape = RoundedCornerShape(8.dp), ) .padding(8.dp), text = generalInfoTooltipText ?: "", color = ExtendedTheme.colors.textPrimary, style = ExtendedTheme.typography.body, textAlign = TextAlign.Center, ) } // slider tooltip val tooltipText = tooltipRanges?.entries?.find { (range, _) -> range.contains(displayValue.toInt()) }?.value ?: "${displayValue.toInt()}%" var tooltipWidth by remember { mutableStateOf(0.dp) } AnimatedVisibility( visible = isDragging, enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), modifier = Modifier .offset( x = with(density) { val basePosition = valueToPosition(displayValue).toDp() + fixedLabelWidth val centeredPosition = basePosition - (tooltipWidth / 2) centeredPosition }, y = (-40).dp, ) .wrapContentSize() .zIndex(10f) .onSizeChanged { size -> tooltipWidth = with(density) { size.width.toDp() } }, ) { Text( modifier = Modifier .wrapContentSize() .background( color = ExtendedTheme.colors.backgroundQuarternary.copy(alpha = 0.9f), shape = RoundedCornerShape(8.dp), ) .padding(8.dp), text = tooltipText, color = ExtendedTheme.colors.textPrimary, style = ExtendedTheme.typography.body, textAlign = TextAlign.Center, ) } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Row( // inner container to host clicking target for label and info icon modifier = Modifier .alpha(0.8f) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { if (generalInfoTooltipText.isNullOrEmpty()) return@clickable displayGeneralTooltip = displayGeneralTooltip.not() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( modifier = Modifier.width(fixedLabelWidth), text = label, style = ExtendedTheme.typography.smallBody, color = ExtendedTheme.colors.textPrimary, overflow = TextOverflow.Ellipsis, maxLines = 1, ) if (!generalInfoTooltipText.isNullOrEmpty()) { Icon( modifier = Modifier.size(20.dp), imageVector = Icons.Filled.Info, tint = ExtendedTheme.colors.textTertiary, contentDescription = generalInfoTooltipText, ) } } Column( modifier = Modifier.weight(1f), ) { Box( modifier = Modifier .fillMaxWidth() .height(36.dp) .padding(horizontal = 4.dp) .onSizeChanged { sliderSize = it } .pointerInput(Unit) { detectTapGestures( onDoubleTap = { onDoubleTap?.invoke() }, ) } .pointerInput(Unit) { detectDragGestures( onDragStart = { offset -> isDragging = true dragPosition = offset.x displayValue = positionToValue(offset.x) onValueChange(displayValue / 100) }, onDragEnd = { // Snap to the closest notch displayValue = positionToValue(dragPosition) onValueChange(displayValue / 100) isDragging = false onDragEnd?.invoke(displayValue / 100) }, onDrag = { change, _ -> dragPosition = (dragPosition + change.position.x - change.previousPosition.x).coerceIn( 0f, sliderSize.width.toFloat(), ) val newDisplayValue = positionToValue(dragPosition) if (newDisplayValue != displayValue) { displayValue = newDisplayValue onValueChange(displayValue / 100) } }, ) }, ) { // notches for (i in 0..steps) { if (i % 5 == 0) { val notchValue = i + valueRange.first val notchPosition = valueToPosition(notchValue.toDouble()) val distanceFromValue = abs(displayValue - notchValue) val maxDistance = 30f.toDouble() val clampedDistance = distanceFromValue.coerceAtMost(maxDistance) val score = kotlin.math.exp(-clampedDistance / 8f).toFloat() Box( modifier = Modifier .offset(x = with(LocalDensity.current) { notchPosition.toDp() }) .alpha(score) .width(2.dp) .height(20.dp) .background(ExtendedTheme.colors.textSecondary) .align(Alignment.CenterStart), ) } } // thumb Box( modifier = Modifier .offset( x = with(LocalDensity.current) { valueToPosition(displayValue).toDp() }, ) .height(24.dp) .width(10.dp) .clip(CircleShape) .background(ExtendedTheme.colors.aukPink) .align(Alignment.CenterStart), ) } } PercentageTextField( value = inputValue, onValueChange = { newValue -> inputValue = newValue }, onValueConfirm = { confirmedValue -> try { val clampedValue = confirmedValue.coerceIn(0f.toDouble(), 100f.toDouble()) inputValue = clampedValue displayValue = clampedValue onValueChange(clampedValue / 100) } catch (e: Exception) { } }, modifier = Modifier.width(48.dp), ) } } } @Composable fun PercentageTextField( value: Double, onValueChange: (Double) -> Unit, onValueConfirm: (Double) -> Unit, modifier: Modifier = Modifier, ) { var textValue by remember { mutableStateOf(if (value == 0.0) "" else value.toInt().toString()) } val focusManager = LocalFocusManager.current LaunchedEffect(value) { textValue = if (value == 0.0) "" else value.toInt().toString() } BasicTextField( modifier = modifier.onFocusEvent { focusState -> if (!focusState.isFocused) { val numericValue = textValue.toDoubleOrNull() ?: 0.0 onValueConfirm(numericValue) } }, value = textValue, onValueChange = { newValue -> val filtered = newValue.filter { it.isDigit() } if (filtered.length <= 2 || filtered == "100") { // Max 100 textValue = filtered val numericValue = filtered.toDoubleOrNull() ?: 0.0 if (numericValue <= 100) { onValueChange(numericValue) } } }, maxLines = 1, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { val numericValue = textValue.toDoubleOrNull() ?: 0.0 onValueConfirm(numericValue) focusManager.clearFocus() }, ), textStyle = ExtendedTheme.typography.smallBody.copy(color = ExtendedTheme.colors.textPrimary), cursorBrush = SolidColor(ExtendedTheme.colors.aukPink), decorationBox = { innerTextField -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier.weight(1f), ) { if (textValue.isEmpty()) { Text( text = "0", style = ExtendedTheme.typography.smallBody.copy( color = ExtendedTheme.colors.textSecondary, ), ) } innerTextField() } Text( text = "%", style = ExtendedTheme.typography.smallBody.copy( color = ExtendedTheme.colors.textPrimary, ), ) } }, ) } @Preview @Composable private fun CustomSlider_Preview() { SunoTheme { CustomSlider( label = "我叫付梦安田村。为什么不知道", generalInfoTooltipText = "This is sparta!!!", value = 0.5f.toDouble(), onValueChange = {}, onDoubleTap = {}, ) } }