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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> = emptyMap(),
|
||||
audioDurations: Map<String, Int> = 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 (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
|
||||
|
||||
if (splitParts.size > 1) {
|
||||
// 多个部分,显示为多个气泡
|
||||
splitParts.forEachIndexed { partIndex, part ->
|
||||
item(key = "${message.id}_part_$partIndex") {
|
||||
AIMessageBubble(text = part)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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