From d060703e643894b4f20612dcd4e1c78438d4039d Mon Sep 17 00:00:00 2001 From: iammm0 Date: Tue, 3 Feb 2026 11:29:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E7=9B=B8=E5=85=B3UI=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增AudioMessageBubble语音消息气泡 - 新增RecordingIndicator录音指示器 - 新增VoiceRecordButton语音录制按钮 - 优化ChatInputField聊天输入框 - 优化MessageList消息列表 Co-authored-by: Cursor --- .../ui/components/chat/AudioMessageBubble.kt | 208 +++++++++++++ .../ui/components/chat/ChatInputField.kt | 174 ++++++++--- .../ui/components/chat/MessageList.kt | 85 +++++- .../ui/components/chat/RecordingIndicator.kt | 128 ++++++++ .../ui/components/chat/VoiceRecordButton.kt | 287 ++++++++++++++++++ 5 files changed, 817 insertions(+), 65 deletions(-) create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/AudioMessageBubble.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/RecordingIndicator.kt create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/AudioMessageBubble.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/AudioMessageBubble.kt new file mode 100644 index 0000000..24a022b --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/AudioMessageBubble.kt @@ -0,0 +1,208 @@ +package com.huaga.life_echo.ui.components.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.huaga.life_echo.feature.voice.PlaybackState +import com.huaga.life_echo.ui.icons.AppIcons +import com.huaga.life_echo.ui.theme.* + +/** + * 用户语音消息气泡(右侧/紫色) + * + * @param messageId 消息 ID + * @param duration 音频时长(秒) + * @param isPlaying 是否正在播放 + * @param playbackProgress 播放进度 0-1 + * @param onPlayClick 播放按钮点击回调 + * @param showAvatar 是否显示头像 + */ +@Composable +fun UserAudioMessageBubble( + messageId: String, + duration: Int, + isPlaying: Boolean, + playbackProgress: Float = 0f, + onPlayClick: () -> Unit, + showAvatar: Boolean = true, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End + ) { + // 气泡 + AudioBubbleContent( + duration = duration, + isPlaying = isPlaying, + playbackProgress = playbackProgress, + onPlayClick = onPlayClick, + isUserMessage = true + ) + + // 用户头像 + if (showAvatar) { + Spacer(modifier = Modifier.width(10.dp)) + MessageAvatar( + isAI = false, + modifier = Modifier.size(AppDimensions.avatarSizeSmall) + ) + } + } +} + +/** + * AI 语音消息气泡(左侧/白色) + * + * @param messageId 消息 ID + * @param duration 音频时长(秒) + * @param isPlaying 是否正在播放 + * @param playbackProgress 播放进度 0-1 + * @param onPlayClick 播放按钮点击回调 + * @param showAvatar 是否显示头像 + */ +@Composable +fun AIAudioMessageBubble( + messageId: String, + duration: Int, + isPlaying: Boolean, + playbackProgress: Float = 0f, + onPlayClick: () -> Unit, + showAvatar: Boolean = true, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Start + ) { + // AI头像 + if (showAvatar) { + MessageAvatar( + isAI = true, + modifier = Modifier.size(AppDimensions.avatarSizeSmall) + ) + Spacer(modifier = Modifier.width(10.dp)) + } + + // 气泡 + AudioBubbleContent( + duration = duration, + isPlaying = isPlaying, + playbackProgress = playbackProgress, + onPlayClick = onPlayClick, + isUserMessage = false + ) + } +} + +/** + * 语音气泡内容 + */ +@Composable +private fun AudioBubbleContent( + duration: Int, + isPlaying: Boolean, + playbackProgress: Float, + onPlayClick: () -> Unit, + isUserMessage: Boolean, + modifier: Modifier = Modifier +) { + // 根据时长计算气泡宽度(最小 100dp,最大 200dp) + val minWidth = 100.dp + val maxWidth = 200.dp + val widthPerSecond = 3.dp + val bubbleWidth = (minWidth + (duration * widthPerSecond.value).dp).coerceIn(minWidth, maxWidth) + + Card( + modifier = modifier + .width(bubbleWidth) + .clickable { onPlayClick() }, + shape = RoundedCornerShape( + topStart = if (isUserMessage) AppDimensions.bubbleRadius else AppDimensions.bubbleCornerRadius, + topEnd = if (isUserMessage) AppDimensions.bubbleCornerRadius else AppDimensions.bubbleRadius, + bottomStart = AppDimensions.bubbleRadius, + bottomEnd = AppDimensions.bubbleRadius + ), + colors = CardDefaults.cardColors( + containerColor = if (isUserMessage) MediumPurple else AppWhite + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 播放/暂停按钮 + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + if (isUserMessage) AppWhite.copy(alpha = 0.2f) else Lavender.copy(alpha = 0.5f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) AppIcons.Pause else AppIcons.PlayArrow, + contentDescription = if (isPlaying) "暂停" else "播放", + tint = if (isUserMessage) AppWhite else MediumPurple, + modifier = Modifier.size(18.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // 波形图 + StaticWaveform( + modifier = Modifier + .weight(1f) + .height(24.dp), + barColor = if (isUserMessage) AppWhite else MediumPurple, + barCount = 15, + progress = playbackProgress + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // 时长 + Text( + text = formatAudioDuration(duration), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = if (isUserMessage) AppWhite.copy(alpha = 0.8f) else SlatePurple + ) + } + } +} + +/** + * 格式化音频时长显示 + */ +private fun formatAudioDuration(seconds: Int): String { + val mins = seconds / 60 + val secs = seconds % 60 + return if (mins > 0) { + "$mins:${secs.toString().padStart(2, '0')}" + } else { + "0:${secs.toString().padStart(2, '0')}" + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt index 137fcb1..bd6d343 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt @@ -11,10 +11,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.huaga.life_echo.ui.icons.AppIcons import com.huaga.life_echo.ui.theme.LightPurple +import com.huaga.life_echo.ui.theme.MediumPurple +import com.huaga.life_echo.ui.theme.SlatePurple /** - * 聊天输入框组件(支持高度自适应和键盘自适应贴合) + * 输入模式 + */ +enum class InputMode { + TEXT, // 文本输入模式 + VOICE // 语音输入模式 +} + +/** + * 聊天输入框组件(支持文本和语音两种模式) */ @Composable fun ChatInputField( @@ -23,9 +34,16 @@ fun ChatInputField( onSend: () -> Unit, modifier: Modifier = Modifier, placeholder: String = "输入消息...", - enabled: Boolean = true + enabled: Boolean = true, + // 语音相关回调 + onStartRecording: () -> Unit = {}, + onStopRecording: () -> Unit = {}, + onCancelRecording: () -> Unit = {}, + isRecording: Boolean = false, + recordingDuration: Int = 0 // 录音时长(秒) ) { var textFieldHeight by remember { mutableStateOf(56.dp) } + var inputMode by remember { mutableStateOf(InputMode.TEXT) } // 使用 windowInsetsPadding 实现键盘自适应贴合,确保输入框紧贴键盘 Surface( @@ -42,59 +60,117 @@ fun ChatInputField( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // 输入框区域 - OutlinedTextField( - value = value, - onValueChange = { newValue -> - onValueChange(newValue) - // 根据内容动态调整高度(模仿微信效果) - val lineCount = newValue.split("\n").size - textFieldHeight = when { - lineCount <= 1 -> 56.dp - lineCount <= 4 -> (56 + (lineCount - 1) * 20).dp - else -> 136.dp // 最大4行 - } + // 语音/键盘切换按钮 + IconButton( + onClick = { + inputMode = if (inputMode == InputMode.TEXT) InputMode.VOICE else InputMode.TEXT }, - modifier = Modifier - .weight(1f) - .heightIn(min = 56.dp, max = 136.dp), - placeholder = { - Text( - text = placeholder, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp - ) - }, - shape = RoundedCornerShape(36.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = LightPurple, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), - singleLine = false, - maxLines = 4, - enabled = enabled - ) + enabled = enabled && !isRecording + ) { + Icon( + imageVector = if (inputMode == InputMode.TEXT) AppIcons.Mic else AppIcons.Keyboard, + contentDescription = if (inputMode == InputMode.TEXT) "切换到语音" else "切换到键盘", + tint = if (enabled) MediumPurple else SlatePurple + ) + } - Spacer(modifier = Modifier.width(8.dp)) - - // 发送按钮 - if (value.isNotBlank() && enabled) { - TextButton( - onClick = onSend, - colors = ButtonDefaults.textButtonColors( - contentColor = LightPurple + when (inputMode) { + InputMode.TEXT -> { + // 文本输入模式 + TextInputContent( + value = value, + onValueChange = { newValue -> + onValueChange(newValue) + // 根据内容动态调整高度(模仿微信效果) + val lineCount = newValue.split("\n").size + textFieldHeight = when { + lineCount <= 1 -> 56.dp + lineCount <= 4 -> (56 + (lineCount - 1) * 20).dp + else -> 136.dp // 最大4行 + } + }, + onSend = onSend, + placeholder = placeholder, + enabled = enabled, + modifier = Modifier.weight(1f) ) - ) { - Text( - text = "发送", - fontSize = 16.sp, - fontWeight = FontWeight.Medium + } + InputMode.VOICE -> { + // 语音输入模式 + VoiceRecordButton( + onStartRecording = onStartRecording, + onStopRecording = onStopRecording, + onCancelRecording = onCancelRecording, + isRecording = isRecording, + recordingDuration = recordingDuration, + enabled = enabled, + modifier = Modifier.weight(1f) ) } } } } } + +/** + * 文本输入内容组件 + */ +@Composable +private fun TextInputContent( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + placeholder: String, + enabled: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // 输入框区域 + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .weight(1f) + .heightIn(min = 56.dp, max = 136.dp), + placeholder = { + Text( + text = placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + }, + shape = RoundedCornerShape(36.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = LightPurple, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), + singleLine = false, + maxLines = 4, + enabled = enabled + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // 发送按钮 + if (value.isNotBlank() && enabled) { + TextButton( + onClick = onSend, + colors = ButtonDefaults.textButtonColors( + contentColor = LightPurple + ) + ) { + Text( + text = "发送", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt index 081db38..d389695 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/MessageList.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.huaga.life_echo.feature.voice.PlaybackInfo +import com.huaga.life_echo.feature.voice.PlaybackState import com.huaga.life_echo.network.models.MessageDto import com.huaga.life_echo.ui.theme.LightPurple import com.huaga.life_echo.utils.TimeUtils @@ -25,6 +27,14 @@ import kotlinx.coroutines.delay /** * 消息列表组件 + * + * @param messages 消息列表 + * @param isStreaming 是否正在流式接收 + * @param streamingText 流式文本内容 + * @param isTyping 是否正在输入 + * @param playbackInfo 音频播放信息 + * @param onAudioPlayClick 音频播放按钮点击回调 + * @param audioFilePaths 音频文件路径映射(messageId -> filePath) */ @Composable fun MessageList( @@ -33,7 +43,12 @@ fun MessageList( streamingText: String = "", isTyping: Boolean = false, modifier: Modifier = Modifier, - onStopGeneration: (() -> Unit)? = null + onStopGeneration: (() -> Unit)? = null, + // 音频相关参数 + playbackInfo: PlaybackInfo = PlaybackInfo(), + onAudioPlayClick: (messageId: String, filePath: String) -> Unit = { _, _ -> }, + audioFilePaths: Map = emptyMap(), + audioDurations: Map = emptyMap() // messageId -> 时长(秒) ) { val listState = rememberLazyListState() @@ -115,26 +130,64 @@ fun MessageList( when (message.senderType) { "user" -> { item(key = message.id) { - UserMessageBubble(text = message.content) + // 判断消息类型 + if (message.messageType == "audio") { + val filePath = audioFilePaths[message.id] ?: "" + val duration = audioDurations[message.id] ?: 0 + val isPlaying = playbackInfo.currentMessageId == message.id && + playbackInfo.state == PlaybackState.PLAYING + val progress = if (playbackInfo.currentMessageId == message.id) + playbackInfo.progress else 0f + + UserAudioMessageBubble( + messageId = message.id, + duration = duration, + isPlaying = isPlaying, + playbackProgress = progress, + onPlayClick = { onAudioPlayClick(message.id, filePath) } + ) + } else { + UserMessageBubble(text = message.content) + } } } "assistant" -> { - // 在 [SPLIT] 处分割消息,显示为多个气泡 - val splitParts = message.content.split("[SPLIT]") - .map { it.trim() } - .filter { it.isNotEmpty() } - - if (splitParts.size > 1) { - // 多个部分,显示为多个气泡 - splitParts.forEachIndexed { partIndex, part -> - item(key = "${message.id}_part_$partIndex") { - AIMessageBubble(text = part) - } + // 判断消息类型 + if (message.messageType == "audio") { + item(key = message.id) { + val filePath = audioFilePaths[message.id] ?: "" + val duration = audioDurations[message.id] ?: 0 + val isPlaying = playbackInfo.currentMessageId == message.id && + playbackInfo.state == PlaybackState.PLAYING + val progress = if (playbackInfo.currentMessageId == message.id) + playbackInfo.progress else 0f + + AIAudioMessageBubble( + messageId = message.id, + duration = duration, + isPlaying = isPlaying, + playbackProgress = progress, + onPlayClick = { onAudioPlayClick(message.id, filePath) } + ) } } else { - // 单个部分,正常显示 - item(key = message.id) { - AIMessageBubble(text = message.content) + // 文本消息 - 在 [SPLIT] 处分割消息,显示为多个气泡 + val splitParts = message.content.split("[SPLIT]") + .map { it.trim() } + .filter { it.isNotEmpty() } + + if (splitParts.size > 1) { + // 多个部分,显示为多个气泡 + splitParts.forEachIndexed { partIndex, part -> + item(key = "${message.id}_part_$partIndex") { + AIMessageBubble(text = part) + } + } + } else { + // 单个部分,正常显示 + item(key = message.id) { + AIMessageBubble(text = message.content) + } } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/RecordingIndicator.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/RecordingIndicator.kt new file mode 100644 index 0000000..6ad6bc5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/RecordingIndicator.kt @@ -0,0 +1,128 @@ +package com.huaga.life_echo.ui.components.chat + +import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import com.huaga.life_echo.ui.theme.AppWhite +import com.huaga.life_echo.ui.theme.Lavender + +/** + * 录音指示器动画组件 + * 显示跳动的波形条 + */ +@Composable +fun RecordingIndicator( + modifier: Modifier = Modifier, + barColor: Color = AppWhite, + barCount: Int = 5 +) { + // 为每个条创建不同相位的动画 + val infiniteTransition = rememberInfiniteTransition(label = "recording") + + // 创建多个动画值,每个有不同的延迟 + val animatedValues = (0 until barCount).map { index -> + infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 400, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(index * 80) + ), + label = "bar$index" + ) + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val totalWidth = size.width + val totalHeight = size.height + + // 计算条的尺寸 + val barWidth = totalWidth / (barCount * 2f) + val spacing = barWidth + val maxBarHeight = totalHeight * 0.8f + val minBarHeight = totalHeight * 0.2f + + // 绘制每个条 + animatedValues.forEachIndexed { index, animatedValue -> + val barHeight = minBarHeight + (maxBarHeight - minBarHeight) * animatedValue.value + val x = (totalWidth - (barCount * barWidth + (barCount - 1) * spacing)) / 2 + + index * (barWidth + spacing) + val y = (totalHeight - barHeight) / 2 + + drawRoundRect( + color = barColor, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth / 2, barWidth / 2) + ) + } + } + } +} + +/** + * 静态波形图组件(用于语音消息气泡) + */ +@Composable +fun StaticWaveform( + modifier: Modifier = Modifier, + barColor: Color = Lavender, + barCount: Int = 20, + progress: Float = 0f // 0-1,用于显示播放进度 +) { + Canvas(modifier = modifier) { + val totalWidth = size.width + val totalHeight = size.height + + // 计算条的尺寸 + val barWidth = totalWidth / (barCount * 1.5f) + val spacing = barWidth * 0.5f + val maxBarHeight = totalHeight * 0.9f + val minBarHeight = totalHeight * 0.2f + + // 预设的波形高度比例(模拟音频波形) + val heightRatios = listOf( + 0.3f, 0.5f, 0.7f, 0.4f, 0.8f, 0.6f, 0.9f, 0.5f, 0.7f, 0.4f, + 0.6f, 0.8f, 0.5f, 0.7f, 0.3f, 0.6f, 0.4f, 0.8f, 0.5f, 0.3f + ) + + // 绘制每个条 + for (index in 0 until barCount) { + val ratio = heightRatios.getOrElse(index % heightRatios.size) { 0.5f } + val barHeight = minBarHeight + (maxBarHeight - minBarHeight) * ratio + val x = index * (barWidth + spacing) + val y = (totalHeight - barHeight) / 2 + + // 根据播放进度决定颜色 + val barProgress = (index + 1).toFloat() / barCount + val color = if (barProgress <= progress) { + barColor + } else { + barColor.copy(alpha = 0.4f) + } + + drawRoundRect( + color = color, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth / 2, barWidth / 2) + ) + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt new file mode 100644 index 0000000..a7cb157 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt @@ -0,0 +1,287 @@ +package com.huaga.life_echo.ui.components.chat + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.huaga.life_echo.ui.icons.AppIcons +import com.huaga.life_echo.ui.theme.* + +/** + * 录音状态 + */ +enum class RecordingState { + IDLE, // 空闲状态 + RECORDING, // 录音中 + CANCEL // 准备取消 +} + +/** + * 语音录音按钮组件(仿微信按住说话) + * + * @param onStartRecording 开始录音回调 + * @param onStopRecording 停止录音并发送回调 + * @param onCancelRecording 取消录音回调 + * @param isRecording 是否正在录音 + * @param recordingDuration 录音时长(秒) + * @param enabled 是否启用 + */ +@Composable +fun VoiceRecordButton( + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onCancelRecording: () -> Unit, + isRecording: Boolean, + recordingDuration: Int, + enabled: Boolean, + modifier: Modifier = Modifier +) { + var recordingState by remember { mutableStateOf(RecordingState.IDLE) } + var dragOffsetY by remember { mutableFloatStateOf(0f) } + + // 取消阈值(上滑超过这个距离就取消) + val cancelThreshold = with(LocalDensity.current) { 80.dp.toPx() } + + // 动画 + val scale by animateFloatAsState( + targetValue = if (recordingState == RecordingState.RECORDING) 1.05f else 1f, + animationSpec = tween(100), + label = "scale" + ) + + val backgroundColor by animateColorAsState( + targetValue = when (recordingState) { + RecordingState.IDLE -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + RecordingState.RECORDING -> Lavender.copy(alpha = 0.5f) + RecordingState.CANCEL -> ErrorRed.copy(alpha = 0.2f) + }, + animationSpec = tween(200), + label = "bgColor" + ) + + // 同步外部录音状态 + LaunchedEffect(isRecording) { + if (!isRecording && recordingState != RecordingState.IDLE) { + recordingState = RecordingState.IDLE + dragOffsetY = 0f + } + } + + Box(modifier = modifier) { + // 录音浮层(浮层上松开也需结束录音,否则事件被浮层拦截按钮收不到抬起) + if (recordingState != RecordingState.IDLE) { + RecordingOverlay( + state = recordingState, + duration = recordingDuration, + onRelease = { + when (recordingState) { + RecordingState.CANCEL -> onCancelRecording() + RecordingState.RECORDING -> onStopRecording() + else -> {} + } + recordingState = RecordingState.IDLE + dragOffsetY = 0f + } + ) + } + + // 按住说话按钮(按下即开始录音,松开结束;上滑取消) + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .scale(scale) + .clip(RoundedCornerShape(36.dp)) + .background(backgroundColor) + .pointerInput(enabled, cancelThreshold) { + if (!enabled) return@pointerInput + + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val changes = event.changes + + // 按下:开始录音(任一指头按下) + val justPressed = changes.any { it.pressed && !it.previousPressed } + if (justPressed) { + recordingState = RecordingState.RECORDING + dragOffsetY = 0f + onStartRecording() + } + // 抬起:结束或取消 + val justReleased = changes.any { !it.pressed && it.previousPressed } + if (justReleased) { + when (recordingState) { + RecordingState.CANCEL -> onCancelRecording() + RecordingState.RECORDING -> onStopRecording() + else -> {} + } + recordingState = RecordingState.IDLE + dragOffsetY = 0f + } + // 跟踪 Y 方向位移用于上滑取消 + if (recordingState == RecordingState.RECORDING || recordingState == RecordingState.CANCEL) { + val deltaY = changes.sumOf { (it.position.y - it.previousPosition.y).toDouble() }.toFloat() + dragOffsetY += deltaY + recordingState = if (-dragOffsetY > cancelThreshold) { + RecordingState.CANCEL + } else { + RecordingState.RECORDING + } + } + } + } + }, + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = if (recordingState == RecordingState.CANCEL) AppIcons.Close else AppIcons.Mic, + contentDescription = null, + tint = when (recordingState) { + RecordingState.IDLE -> SlatePurple + RecordingState.RECORDING -> MediumPurple + RecordingState.CANCEL -> ErrorRed + }, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = when (recordingState) { + RecordingState.IDLE -> "按住说话" + RecordingState.RECORDING -> "松开发送" + RecordingState.CANCEL -> "松开取消" + }, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = when (recordingState) { + RecordingState.IDLE -> SlatePurple + RecordingState.RECORDING -> DeepPurple + RecordingState.CANCEL -> ErrorRed + } + ) + } + } + } +} + +/** + * 录音浮层组件:在全屏中央显示。 + * 浮层会拦截触摸,因此在此处理「松开」事件并回调 onRelease,避免按钮收不到抬起而卡住。 + */ +@Composable +private fun RecordingOverlay( + state: RecordingState, + duration: Int, + onRelease: () -> Unit +) { + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp.dp + val screenHeightDp = configuration.screenHeightDp.dp + + Popup( + alignment = Alignment.Center, + properties = PopupProperties( + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + // 全屏半透明遮罩,计时卡片居中;在遮罩上监听抬起以结束录音 + Box( + modifier = Modifier + .size(screenWidthDp, screenHeightDp) + .background(Color.Black.copy(alpha = 0.3f)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val justReleased = event.changes.any { !it.pressed && it.previousPressed } + if (justReleased) { + onRelease() + break + } + } + } + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(160.dp) + .clip(RoundedCornerShape(16.dp)) + .background( + if (state == RecordingState.CANCEL) + ErrorRed.copy(alpha = 0.9f) + else + DeepPurple.copy(alpha = 0.9f) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // 录音动画或取消图标 + if (state == RecordingState.CANCEL) { + Icon( + imageVector = AppIcons.Close, + contentDescription = null, + tint = AppWhite, + modifier = Modifier.size(48.dp) + ) + } else { + RecordingIndicator( + modifier = Modifier.size(48.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = formatDuration(duration), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = AppWhite + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (state == RecordingState.CANCEL) "松开取消" else "上滑取消", + fontSize = 13.sp, + color = AppWhite.copy(alpha = 0.8f) + ) + } + } + } + } +} + +/** + * 格式化录音时长 + */ +private fun formatDuration(seconds: Int): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "$mins:${secs.toString().padStart(2, '0')}" +}