feat: 新增语音消息相关UI组件
- 新增AudioMessageBubble语音消息气泡 - 新增RecordingIndicator录音指示器 - 新增VoiceRecordButton语音录制按钮 - 优化ChatInputField聊天输入框 - 优化MessageList消息列表 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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')}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,21 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.LightPurple
|
||||||
|
import com.huaga.life_echo.ui.theme.MediumPurple
|
||||||
|
import com.huaga.life_echo.ui.theme.SlatePurple
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聊天输入框组件(支持高度自适应和键盘自适应贴合)
|
* 输入模式
|
||||||
|
*/
|
||||||
|
enum class InputMode {
|
||||||
|
TEXT, // 文本输入模式
|
||||||
|
VOICE // 语音输入模式
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天输入框组件(支持文本和语音两种模式)
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInputField(
|
fun ChatInputField(
|
||||||
@@ -23,9 +34,16 @@ fun ChatInputField(
|
|||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
placeholder: String = "输入消息...",
|
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 textFieldHeight by remember { mutableStateOf(56.dp) }
|
||||||
|
var inputMode by remember { mutableStateOf(InputMode.TEXT) }
|
||||||
|
|
||||||
// 使用 windowInsetsPadding 实现键盘自适应贴合,确保输入框紧贴键盘
|
// 使用 windowInsetsPadding 实现键盘自适应贴合,确保输入框紧贴键盘
|
||||||
Surface(
|
Surface(
|
||||||
@@ -42,8 +60,24 @@ fun ChatInputField(
|
|||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// 输入框区域
|
// 语音/键盘切换按钮
|
||||||
OutlinedTextField(
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
inputMode = if (inputMode == InputMode.TEXT) InputMode.VOICE else InputMode.TEXT
|
||||||
|
},
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (inputMode) {
|
||||||
|
InputMode.TEXT -> {
|
||||||
|
// 文本输入模式
|
||||||
|
TextInputContent(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = { newValue ->
|
onValueChange = { newValue ->
|
||||||
onValueChange(newValue)
|
onValueChange(newValue)
|
||||||
@@ -55,6 +89,49 @@ fun ChatInputField(
|
|||||||
else -> 136.dp // 最大4行
|
else -> 136.dp // 最大4行
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSend = onSend,
|
||||||
|
placeholder = placeholder,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.heightIn(min = 56.dp, max = 136.dp),
|
.heightIn(min = 56.dp, max = 136.dp),
|
||||||
@@ -96,5 +173,4 @@ fun ChatInputField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.network.models.MessageDto
|
||||||
import com.huaga.life_echo.ui.theme.LightPurple
|
import com.huaga.life_echo.ui.theme.LightPurple
|
||||||
import com.huaga.life_echo.utils.TimeUtils
|
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
|
@Composable
|
||||||
fun MessageList(
|
fun MessageList(
|
||||||
@@ -33,7 +43,12 @@ fun MessageList(
|
|||||||
streamingText: String = "",
|
streamingText: String = "",
|
||||||
isTyping: Boolean = false,
|
isTyping: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onStopGeneration: (() -> Unit)? = null
|
onStopGeneration: (() -> Unit)? = null,
|
||||||
|
// 音频相关参数
|
||||||
|
playbackInfo: PlaybackInfo = PlaybackInfo(),
|
||||||
|
onAudioPlayClick: (messageId: String, filePath: String) -> Unit = { _, _ -> },
|
||||||
|
audioFilePaths: Map<String, String> = emptyMap(),
|
||||||
|
audioDurations: Map<String, Int> = emptyMap() // messageId -> 时长(秒)
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
@@ -115,11 +130,48 @@ fun MessageList(
|
|||||||
when (message.senderType) {
|
when (message.senderType) {
|
||||||
"user" -> {
|
"user" -> {
|
||||||
item(key = message.id) {
|
item(key = message.id) {
|
||||||
|
// 判断消息类型
|
||||||
|
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)
|
UserMessageBubble(text = message.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"assistant" -> {
|
"assistant" -> {
|
||||||
// 在 [SPLIT] 处分割消息,显示为多个气泡
|
// 判断消息类型
|
||||||
|
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 {
|
||||||
|
// 文本消息 - 在 [SPLIT] 处分割消息,显示为多个气泡
|
||||||
val splitParts = message.content.split("[SPLIT]")
|
val splitParts = message.content.split("[SPLIT]")
|
||||||
.map { it.trim() }
|
.map { it.trim() }
|
||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
@@ -139,6 +191,7 @@ fun MessageList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lastDate = currentDate
|
lastDate = currentDate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')}"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user