feat: 新增语音消息相关UI组件

- 新增AudioMessageBubble语音消息气泡
- 新增RecordingIndicator录音指示器
- 新增VoiceRecordButton语音录制按钮
- 优化ChatInputField聊天输入框
- 优化MessageList消息列表

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-03 11:29:53 +08:00
parent 6dfa010b28
commit d060703e64
5 changed files with 817 additions and 65 deletions

View File

@@ -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')}"
}
}

View File

@@ -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(
} }
} }
} }
}
} }

View File

@@ -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
} }

View File

@@ -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)
)
}
}
}

View File

@@ -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')}"
}