diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt new file mode 100644 index 00000000..254f6958 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChrome.kt @@ -0,0 +1,470 @@ +package com.team.prezel.core.designsystem.component.voice + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +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.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.R +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.LargeDevicePreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSurface +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Immutable +enum class VoiceChromeStatus { + IDLE, + LISTENING, + WAITING, +} + +@Immutable +enum class VoiceChromeGradient { + NONE, + MIN, + MAX, +} + +@Composable +fun PrezelVoiceChrome( + titleText: String, + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + gradient: VoiceChromeGradient = VoiceChromeGradient.NONE, +) { + val shouldAnimateGradient = status == VoiceChromeStatus.LISTENING && + gradient != VoiceChromeGradient.NONE + val gradientStop = if (shouldAnimateGradient) { + val transition = rememberInfiniteTransition(label = "VoiceChromeGradientTransition") + val animatedGradientStop by transition.animateFloat( + initialValue = gradient.initialAnimatedStop, + targetValue = gradient.targetAnimatedStop, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2400, + delayMillis = 160, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "VoiceChromeGradientStop", + ) + animatedGradientStop + } else { + VoiceChromeGradient.NONE.stop + } + + PrezelVoiceChromeContent( + titleText = titleText, + modifier = modifier, + status = status, + gradientStop = gradientStop, + ) +} + +@Composable +private fun PrezelVoiceChromeContent( + titleText: String, + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + gradientStop: Float = VoiceChromeGradient.NONE.stop, +) { + val targetLineColor = when (status) { + VoiceChromeStatus.IDLE, + VoiceChromeStatus.LISTENING, + -> PrezelTheme.colors.interactiveRegular + + VoiceChromeStatus.WAITING -> PrezelTheme.colors.borderLarge + } + val lineColor by animateColorAsState( + targetValue = targetLineColor, + animationSpec = tween(durationMillis = 280), + label = "VoiceChromeLineColor", + ) + val targetTitleColor = when (status) { + VoiceChromeStatus.IDLE, + VoiceChromeStatus.LISTENING, + -> PrezelTheme.colors.interactiveRegular + + VoiceChromeStatus.WAITING -> PrezelTheme.colors.textMedium + } + val titleColor by animateColorAsState( + targetValue = targetTitleColor, + animationSpec = tween(durationMillis = 280), + label = "VoiceChromeTitleColor", + ) + + Box( + modifier = modifier + .size(width = 360.dp, height = 160.dp) + .voiceChromeBackground( + status = status, + gradientStop = gradientStop, + color = PrezelTheme.colors.interactiveRegular, + ), + contentAlignment = Alignment.Center, + ) { + VoiceChromeTitle( + titleText = titleText, + status = status, + color = titleColor, + ) + + VoiceChromeLine( + status = status, + color = lineColor, + ) + } +} + +@Composable +private fun VoiceChromeTitle( + titleText: String, + status: VoiceChromeStatus, + color: Color, +) { + val baseStyle = PrezelTheme.typography.title1Bold.copy(textAlign = TextAlign.Center) + + when (status) { + VoiceChromeStatus.IDLE -> Text( + text = titleText, + style = baseStyle.withIdleTitleBrush(), + ) + + VoiceChromeStatus.LISTENING -> Text( + text = stringResource(R.string.core_designsystem_voice_chrome_listening), + style = baseStyle, + color = color, + ) + + VoiceChromeStatus.WAITING -> Text( + text = stringResource(R.string.core_designsystem_voice_chrome_waiting), + style = baseStyle, + color = color, + ) + } +} + +@Composable +private fun TextStyle.withIdleTitleBrush(): TextStyle { + val colors = PrezelTheme.colors + + return copy( + brush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to colors.interactiveSmall, + 0.5f to colors.interactiveRegular, + 1f to colors.interactiveSmall, + ), + ), + ) +} + +@Composable +private fun BoxScope.VoiceChromeLine( + status: VoiceChromeStatus, + color: Color, +) { + val lineProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.IDLE) 0f else 1f, + animationSpec = tween( + durationMillis = 560, + easing = CubicBezierEasing(0f, 0f, 0.58f, 1f), + ), + label = "VoiceChromeLineProgress", + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(4.dp) + .drawWithCache { + val lineBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to color.copy(alpha = 0f), + 0.5f to color, + 1f to color.copy(alpha = 0f), + ), + ) + + onDrawBehind { + val halfLineWidth = size.width * lineProgress / 2f + clipRect( + left = size.width / 2f - halfLineWidth, + right = size.width / 2f + halfLineWidth, + ) { + drawRect(brush = lineBrush) + } + } + }, + ) +} + +private fun Modifier.voiceChromeBackground( + status: VoiceChromeStatus, + gradientStop: Float, + color: Color, +): Modifier = + drawWithCache { + val shouldDrawGradient = status == VoiceChromeStatus.LISTENING && gradientStop > 0f + val gradientBrush = if (shouldDrawGradient) { + Brush.radialGradient( + 0f to color.copy(alpha = 1f), + gradientStop * 0.12f to color.copy(alpha = 0.86f), + gradientStop * 0.24f to color.copy(alpha = 0.68f), + gradientStop * 0.38f to color.copy(alpha = 0.48f), + gradientStop * 0.54f to color.copy(alpha = 0.30f), + gradientStop * 0.72f to color.copy(alpha = 0.14f), + gradientStop * 0.88f to color.copy(alpha = 0.05f), + gradientStop to color.copy(alpha = 0f), + center = Offset(size.width / 2f, size.height), + radius = size.width, + ) + } else { + null + } + + onDrawBehind { + if (gradientBrush != null) { + clipRect { + withTransform( + { + scale( + scaleX = 1f, + scaleY = 0.45f, + pivot = Offset(size.width / 2f, size.height), + ) + }, + ) { + drawCircle( + brush = gradientBrush, + radius = size.width, + center = Offset(size.width / 2f, size.height), + ) + } + } + } + } + } + +private val VoiceChromeGradient.stop: Float + get() = when (this) { + VoiceChromeGradient.NONE -> 0f + VoiceChromeGradient.MIN -> 0.28f + VoiceChromeGradient.MAX -> 0.44f + } + +private val VoiceChromeGradient.initialAnimatedStop: Float + get() = when (this) { + VoiceChromeGradient.NONE, + VoiceChromeGradient.MIN, + -> VoiceChromeGradient.MIN.stop + + VoiceChromeGradient.MAX -> VoiceChromeGradient.MAX.stop + } + +private val VoiceChromeGradient.targetAnimatedStop: Float + get() = when (this) { + VoiceChromeGradient.NONE, + VoiceChromeGradient.MIN, + -> VoiceChromeGradient.MAX.stop + + VoiceChromeGradient.MAX -> VoiceChromeGradient.MIN.stop + } + +@LargeDevicePreview +@Composable +private fun PrezelVoiceChromeComponentPreview() { + PreviewSurface { + PreviewColumn { + VoiceChromeStatusPreviewSection() + Spacer(modifier = Modifier.height(4.dp)) + VoiceChromeGradientPreviewSection() + } + } +} + +@Composable +private fun VoiceChromeStatusPreviewSection() { + VoiceChromePreviewSection(title = "Status") { + Row { + VoiceChromePreviewItem(label = "Status - Idle") { + PrezelVoiceChrome(titleText = "지금부터 발표해볼까요?") + } + VoiceChromePreviewItem(label = "Status - Listening") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MIN, + ) + } + VoiceChromePreviewItem(label = "Status - Waiting") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.WAITING, + ) + } + } + } +} + +@Composable +private fun VoiceChromeGradientPreviewSection() { + VoiceChromePreviewSection(title = "Gradient") { + Row { + VoiceChromePreviewItem(label = "Gradient - None") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.NONE, + ) + } + VoiceChromePreviewItem(label = "Gradient - Min") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MIN, + ) + } + VoiceChromePreviewItem(label = "Gradient - Max") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MAX, + ) + } + } + } +} + +@Composable +private fun VoiceChromePreviewSection( + title: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = title, + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@Composable +private fun VoiceChromePreviewItem( + label: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = label, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeIdleMinTogglePreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.IDLE) } + + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Idle <-> Min") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + modifier = Modifier.clickable { + status = if (status == VoiceChromeStatus.IDLE) { + VoiceChromeStatus.LISTENING + } else { + VoiceChromeStatus.IDLE + } + }, + status = status, + gradient = VoiceChromeGradient.MIN, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeMaxWaitingTogglePreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.LISTENING) } + + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Max <-> Waiting") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + modifier = Modifier.clickable { + status = if (status == VoiceChromeStatus.WAITING) { + VoiceChromeStatus.LISTENING + } else { + VoiceChromeStatus.WAITING + } + }, + status = status, + gradient = VoiceChromeGradient.MAX, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeMaxMinMaxPreview() { + PreviewSurface { + PreviewColumn { + VoiceChromePreviewItem(label = "Max <-> Min") { + PrezelVoiceChrome( + titleText = "지금부터 발표해볼까요?", + status = VoiceChromeStatus.LISTENING, + gradient = VoiceChromeGradient.MAX, + ) + } + } + } +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt new file mode 100644 index 00000000..2ae0779d --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -0,0 +1,479 @@ +package com.team.prezel.core.designsystem.component.voice + +import androidx.annotation.FloatRange +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.preview.LargeDevicePreview +import com.team.prezel.core.designsystem.preview.PreviewColumn +import com.team.prezel.core.designsystem.preview.PreviewSurface +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.math.roundToInt + +@Composable +fun PrezelVoiceChromeWave( + modifier: Modifier = Modifier, + status: VoiceChromeStatus = VoiceChromeStatus.IDLE, + volumes: ImmutableList = persistentListOf(), + showBaseline: Boolean = true, +) { + val adjustedVolumes = when (status) { + VoiceChromeStatus.IDLE -> persistentListOf() + + VoiceChromeStatus.LISTENING, + VoiceChromeStatus.WAITING, + -> { + val clippedVolumes = volumes.map { volume -> + volume.coerceIn( + minimumValue = 0.1f, + maximumValue = 1f, + ) + } + + clippedVolumes.toImmutableList() + } + } + val activationProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.IDLE) 0f else 1f, + animationSpec = tween(durationMillis = 6400), + label = "VoiceChromeWaveActivationProgress", + ) + val volumeProgress by animateFloatAsState( + targetValue = if (status == VoiceChromeStatus.LISTENING) 1f else 0f, + animationSpec = tween(durationMillis = 440), + label = "VoiceChromeWaveVolumeProgress", + ) + + Spacer( + modifier = modifier.drawVoiceChromeWave( + status = status, + volumes = adjustedVolumes, + activationProgress = activationProgress, + volumeProgress = volumeProgress, + showBaseline = showBaseline, + ), + ) +} + +@Composable +private fun Modifier.drawVoiceChromeWave( + status: VoiceChromeStatus, + volumes: ImmutableList, + activationProgress: Float, + volumeProgress: Float, + showBaseline: Boolean, +): Modifier { + val colors = PrezelTheme.colors + + return size(width = 360.dp, height = 60.dp).drawWithCache { + val barWidth = 2.dp.toPx() + val barSpacing = 6.dp.toPx() + val minBarHeight = 4.dp.toPx() + val maxBarHeight = 40.dp.toPx() + val barRadius = CornerRadius(barWidth / 2f, barWidth / 2f) + val activeBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to colors.interactiveSmall, + 0.5f to colors.interactiveRegular, + 1f to colors.interactiveSmall, + ), + startX = 0f, + endX = size.width, + ) + val drawConfig = VoiceChromeWaveDrawConfig( + barWidth = barWidth, + barSpacing = barSpacing, + minBarHeight = minBarHeight, + maxBarHeight = maxBarHeight, + barRadius = barRadius, + activeBrush = activeBrush, + waitingColor = colors.interactiveXSmall, + idleColor = colors.bgDisabled, + baselineStrokeWidth = 1.dp.toPx(), + ) + + onDrawBehind { + drawVoiceChromeWaveContent( + status = status, + volumes = volumes, + config = drawConfig, + activationProgress = activationProgress, + volumeProgress = volumeProgress, + showBaseline = showBaseline, + baselineColor = colors.borderRegular, + ) + } + } +} + +private data class VoiceChromeWaveDrawConfig( + val barWidth: Float, + val barSpacing: Float, + val minBarHeight: Float, + val maxBarHeight: Float, + val barRadius: CornerRadius, + val activeBrush: Brush, + val waitingColor: Color, + val idleColor: Color, + val baselineStrokeWidth: Float, +) + +private fun DrawScope.drawVoiceChromeWaveContent( + status: VoiceChromeStatus, + volumes: ImmutableList, + config: VoiceChromeWaveDrawConfig, + activationProgress: Float, + volumeProgress: Float, + showBaseline: Boolean, + baselineColor: Color, +) { + if (status == VoiceChromeStatus.LISTENING && activationProgress < 1f) { + drawVoiceChromeWaveBars( + status = VoiceChromeStatus.IDLE, + volumes = persistentListOf(), + config = config, + xOffset = -size.width * activationProgress, + volumeProgress = 0f, + ) + drawVoiceChromeWaveBars( + status = VoiceChromeStatus.LISTENING, + volumes = volumes, + config = config, + xOffset = size.width * (1f - activationProgress), + volumeProgress = volumeProgress, + ) + } else { + drawVoiceChromeWaveBars( + status = status, + volumes = volumes, + config = config, + xOffset = 0f, + volumeProgress = volumeProgress, + ) + } + + drawVoiceChromeWaveBaseline( + visible = showBaseline, + color = baselineColor, + strokeWidth = config.baselineStrokeWidth, + ) +} + +private fun DrawScope.drawVoiceChromeWaveBars( + status: VoiceChromeStatus, + volumes: ImmutableList, + config: VoiceChromeWaveDrawConfig, + xOffset: Float, + volumeProgress: Float, +) { + var barX = -config.barWidth + xOffset + var barIndex = 0 + val barCount = (size.width / config.barSpacing).roundToInt() + 1 + + while (barX < size.width + config.barSpacing) { + val volume = volumes.sampleVolume( + index = barIndex, + sampleCount = barCount, + ) + val barHeight = config.volumeToBarHeight( + volume = volume, + progress = volumeProgress, + ) + val barTop = (size.height - barHeight) / 2f + val topLeft = Offset(x = barX, y = barTop) + val barSize = Size(width = config.barWidth, height = barHeight) + + when (status) { + VoiceChromeStatus.IDLE -> drawRoundRect( + color = config.idleColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.LISTENING -> drawRoundRect( + brush = config.activeBrush, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.WAITING -> drawRoundRect( + color = config.waitingColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + } + + barX += config.barSpacing + barIndex += 1 + } +} + +private fun DrawScope.drawVoiceChromeWaveBaseline( + visible: Boolean, + color: Color, + strokeWidth: Float, +) { + if (!visible) return + + drawLine( + color = color, + start = Offset(x = 0f, y = size.height / 2f), + end = Offset(x = size.width, y = size.height / 2f), + strokeWidth = strokeWidth, + ) +} + +private fun VoiceChromeWaveDrawConfig.volumeToBarHeight( + volume: Float, + progress: Float, +): Float { + val volumeProgress = (volume - 0.1f) / (1f - 0.1f) + val targetHeight = minBarHeight + volumeProgress * (maxBarHeight - minBarHeight) + + return minBarHeight + (targetHeight - minBarHeight) * progress +} + +private fun ImmutableList.sampleVolume( + index: Int, + sampleCount: Int, +): Float { + if (isEmpty()) return 0.1f + if (size == 1 || sampleCount <= 1) return first() + + val sampleIndex = (index * (lastIndex.toFloat() / (sampleCount - 1))).roundToInt() + return get(sampleIndex.coerceIn(indices)) +} + +@LargeDevicePreview +@Composable +private fun PrezelVoiceChromeWaveComponentPreview() { + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewSection( + title = "Show Baseline - True", + showBaseline = true, + ) + VoiceChromeWavePreviewSection( + title = "Show Baseline - False", + showBaseline = false, + ) + VoiceChromeWaveVolumePreviewSection() + } + } +} + +@Composable +private fun VoiceChromeWavePreviewSection( + title: String, + showBaseline: Boolean, +) { + Column { + Text( + text = title, + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + VoiceChromeWaveStatusPreviewRow(showBaseline = showBaseline) + } +} + +@Composable +private fun VoiceChromeWaveStatusPreviewRow(showBaseline: Boolean) { + Row { + VoiceChromeWavePreviewItem(label = "Status - Idle") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.IDLE, + showBaseline = showBaseline, + ) + } + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWavePreviewItem(label = "Status - Listening") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.LISTENING, + volumes = previewVolumes( + peakVolume = 0.75f, + ), + showBaseline = showBaseline, + ) + } + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWavePreviewItem(label = "Status - Waiting") { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.WAITING, + showBaseline = showBaseline, + ) + } + } +} + +@Composable +private fun VoiceChromeWaveVolumePreviewSection() { + Column { + Text( + text = "Volume", + style = PrezelTheme.typography.body2Bold, + color = PrezelTheme.colors.textLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row { + VoiceChromeWaveVolumePreviewItem( + label = "Volume - Min", + peakVolume = 0.1f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - Max", + peakVolume = 1f, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row { + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 25%", + peakVolume = 0.25f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 50%", + peakVolume = 0.5f, + ) + Spacer(modifier = Modifier.width(20.dp)) + VoiceChromeWaveVolumePreviewItem( + label = "Volume - 75%", + peakVolume = 0.75f, + ) + } + } +} + +@Composable +private fun VoiceChromeWaveVolumePreviewItem( + label: String, + @FloatRange(from = 0.0, to = 1.0) peakVolume: Float, +) { + VoiceChromeWavePreviewItem(label = label) { + PrezelVoiceChromeWave( + status = VoiceChromeStatus.LISTENING, + volumes = previewVolumes( + peakVolume = peakVolume, + ), + ) + } +} + +@Composable +private fun VoiceChromeWavePreviewItem( + label: String, + content: @Composable () -> Unit, +) { + Column { + Text( + text = label, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeWaveIdleToListeningPreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.IDLE) } + + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewItem(label = "Idle > Listening") { + PrezelVoiceChromeWave( + modifier = Modifier.clickable { + status = VoiceChromeStatus.LISTENING + }, + status = status, + volumes = previewVolumes( + peakVolume = 1f, + ), + showBaseline = false, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PrezelVoiceChromeWaveListeningToWaitingPreview() { + var status by remember { mutableStateOf(VoiceChromeStatus.LISTENING) } + + PreviewSurface { + PreviewColumn { + VoiceChromeWavePreviewItem(label = "Listening > Waiting") { + PrezelVoiceChromeWave( + modifier = Modifier.clickable { + status = VoiceChromeStatus.WAITING + }, + status = status, + volumes = previewVolumes( + peakVolume = 1f, + ), + showBaseline = false, + ) + } + } + } +} + +private fun previewVolumes( + @FloatRange(from = 0.0, to = 1.0) peakVolume: Float, +): ImmutableList = + List(61) { index -> + val volume = PreviewVolumePattern[index % PreviewVolumePattern.size] + 0.1f + (volume - 0.1f) * ((peakVolume - 0.1f) / 0.9f) + }.toImmutableList() + +private val PreviewVolumePattern = listOf( + 0.24f, + 0.42f, + 0.30f, + 0.56f, + 0.38f, + 0.70f, + 0.48f, + 0.86f, + 1.00f, + 0.78f, + 0.62f, + 0.44f, + 0.58f, + 0.36f, + 0.28f, +) diff --git a/Prezel/core/designsystem/src/main/res/values/strings.xml b/Prezel/core/designsystem/src/main/res/values/strings.xml index 70523987..a51ac86d 100644 --- a/Prezel/core/designsystem/src/main/res/values/strings.xml +++ b/Prezel/core/designsystem/src/main/res/values/strings.xml @@ -13,6 +13,8 @@ 스크립트 일치 트랙 발화 트랙 툴팁 닫기 + 듣고 있어요 + 일시정지됨