refactor: 优化创建回忆、个人资料及语音组件

- 优化CreateMemoryScreen、ProfileScreen
- 优化ChatInputField、VoiceRecordButton
- 新增ErrorDebugPanel调试面板

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
iammm0
2026-02-10 14:24:09 +08:00
parent 4f7a4c3ad4
commit e8e5fcb82b
5 changed files with 213 additions and 120 deletions

View File

@@ -39,6 +39,7 @@ fun ChatInputField(
onStartRecording: () -> Unit = {},
onStopRecording: () -> Unit = {},
onCancelRecording: () -> Unit = {},
onRecordingStateChange: (RecordingState) -> Unit = {},
isRecording: Boolean = false,
recordingDuration: Int = 0 // 录音时长(秒)
) {
@@ -101,6 +102,7 @@ fun ChatInputField(
onStartRecording = onStartRecording,
onStopRecording = onStopRecording,
onCancelRecording = onCancelRecording,
onRecordingStateChange = onRecordingStateChange,
isRecording = isRecording,
recordingDuration = recordingDuration,
enabled = enabled,

View File

@@ -18,11 +18,8 @@ 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.*
@@ -37,10 +34,12 @@ enum class RecordingState {
/**
* 语音录音按钮组件(仿微信按住说话)
*
* 松开即发送:浮层仅做视觉反馈,不拦截触摸,由按钮直接接收 release 并触发发送。
*
* @param onStartRecording 开始录音回调
* @param onStopRecording 停止录音并发送回调
* @param onCancelRecording 取消录音回调
* @param onRecordingStateChange 录音状态变化(用于上层显示浮层,不消费触摸)
* @param isRecording 是否正在录音
* @param recordingDuration 录音时长(秒)
* @param enabled 是否启用
@@ -50,6 +49,7 @@ fun VoiceRecordButton(
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
onRecordingStateChange: (RecordingState) -> Unit = {},
isRecording: Boolean,
recordingDuration: Int,
enabled: Boolean,
@@ -85,26 +85,13 @@ fun VoiceRecordButton(
dragOffsetY = 0f
}
}
// 通知上层当前录音状态(用于全屏浮层展示,浮层不消费触摸,松开由本按钮接收并立即发送)
LaunchedEffect(recordingState) {
onRecordingStateChange(recordingState)
}
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()
@@ -188,90 +175,61 @@ fun VoiceRecordButton(
}
/**
* 录音浮层组件:在全屏中央显示
* 浮层会拦截触摸,因此在此处理「松开」事件并回调 onRelease避免按钮收不到抬起而卡住
* 录音浮层纯视觉内容:全屏半透明遮罩 + 居中计时卡片
* 不处理触摸(无 pointerInput松开由 VoiceRecordButton 接收并立即发送
* 供上层在根布局用 Box 展示,避免 Popup 拦截导致需二次点击才发送。
*/
@Composable
private fun RecordingOverlay(
fun RecordingOverlayContent(
state: RecordingState,
duration: Int,
onRelease: () -> Unit
modifier: Modifier = Modifier
) {
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.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
// 全屏半透明遮罩,计时卡片居中;在遮罩上监听抬起以结束录音
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
}
}
}
},
.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
) {
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
) {
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
if (state == RecordingState.CANCEL) {
Icon(
imageVector = AppIcons.Close,
contentDescription = null,
tint = AppWhite,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (state == RecordingState.CANCEL) "松开取消" else "上滑取消",
fontSize = 13.sp,
color = AppWhite.copy(alpha = 0.8f)
} 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)
)
}
}
}

View File

@@ -0,0 +1,134 @@
package com.huaga.life_echo.ui.components.debug
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.ui.icons.AppIcons
/**
* 加载失败页的调试面板在调试模式下展示异常类型、完整堆栈等信息便于排查「The coroutine scope left the composition」等错误。
* 仅在 [AppConfig.isDebugMode] 为 true 且 [debugInfo] 非空时显示。
*/
@Composable
fun ErrorDebugPanel(
debugInfo: String?,
screenName: String = "当前页",
modifier: Modifier = Modifier
) {
if (!AppConfig.isDebugMode || debugInfo.isNullOrBlank()) return
var isExpanded by remember { mutableStateOf(true) }
val context = LocalContext.current
val clipboardManager = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(
color = MaterialTheme.colorScheme.error,
shape = RoundedCornerShape(4.dp)
)
)
Text(
text = "错误调试 · $screenName",
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Icon(
imageVector = if (isExpanded) AppIcons.ExpandLess else AppIcons.ExpandMore,
contentDescription = if (isExpanded) "收起" else "展开",
modifier = Modifier.padding(4.dp),
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
) {
TextButton(
onClick = {
clipboardManager.setPrimaryClip(
ClipData.newPlainText("ErrorDebug", debugInfo)
)
}
) {
Text("复制完整信息", fontSize = 12.sp)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = debugInfo,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp)
)
}
}
}
}
}

View File

@@ -26,7 +26,11 @@ import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.data.database.Message
import com.huaga.life_echo.feature.voice.PlaybackInfo
import com.huaga.life_echo.network.models.MessageDto
import com.huaga.life_echo.ui.components.chat.*
import com.huaga.life_echo.ui.components.chat.ChatHeader
import com.huaga.life_echo.ui.components.chat.ChatInputField
import com.huaga.life_echo.ui.components.chat.MessageList
import com.huaga.life_echo.ui.components.chat.RecordingOverlayContent
import com.huaga.life_echo.ui.components.chat.RecordingState
import com.huaga.life_echo.ui.components.debug.WebSocketDebugPanel
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.ui.viewmodel.CreateMemoryViewModel
@@ -55,6 +59,11 @@ fun CreateMemoryScreen(
// 语音录制相关状态
val isVoiceRecording by viewModel.isVoiceRecording.collectAsState()
val recordingDuration by viewModel.recordingDuration.collectAsState()
// 录音浮层状态(由按钮上报,用于全屏浮层;浮层不消费触摸,松开由按钮接收并立即发送)
var recordingOverlayState by remember { mutableStateOf<RecordingState>(RecordingState.IDLE) }
LaunchedEffect(isVoiceRecording) {
if (!isVoiceRecording) recordingOverlayState = RecordingState.IDLE
}
// 音频播放相关状态
val playbackInfo by viewModel.playbackInfo.collectAsState()
@@ -142,10 +151,9 @@ fun CreateMemoryScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.fillMaxSize()
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
modifier = Modifier.fillMaxSize()
) {
// 使用新的ChatHeader组件内部已处理WindowInsets
ChatHeader(
@@ -213,9 +221,19 @@ fun CreateMemoryScreen(
onCancelRecording = {
viewModel.cancelRecordingVoice()
},
onRecordingStateChange = { recordingOverlayState = it },
isRecording = isVoiceRecording,
recordingDuration = recordingDuration
)
}
// 录音浮层:仅视觉反馈,不消费触摸,松开由按钮接收并立即发送
if (recordingOverlayState != RecordingState.IDLE) {
RecordingOverlayContent(
state = recordingOverlayState,
duration = recordingDuration,
modifier = Modifier.fillMaxSize()
)
}
}
}
}

View File

@@ -71,7 +71,6 @@ fun ProfileScreen(
var darkMode by remember { mutableStateOf(AppSettings.darkMode) }
var speechRate by remember { mutableStateOf(AppSettings.speechRate) }
var showSpeechRateDialog by remember { mutableStateOf(false) }
var showUpgradePlanDialog by remember { mutableStateOf(false) }
val isLoggedIn by authViewModel.isLoggedIn.collectAsState()
val currentUser by authViewModel.currentUser.collectAsState()
@@ -131,25 +130,6 @@ fun ProfileScreen(
)
}
// 升级套餐提示对话框
if (showUpgradePlanDialog) {
AlertDialog(
onDismissRequest = { showUpgradePlanDialog = false },
title = { Text("功能开发中") },
text = {
Text(
text = "升级/管理套餐功能正在开发中,敬请期待!",
modifier = Modifier.wrapContentHeight()
)
},
confirmButton = {
TextButton(onClick = { showUpgradePlanDialog = false }) {
Text("确定", color = MediumPurple)
}
}
)
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -167,8 +147,9 @@ fun ProfileScreen(
planName = if (isLoggedIn && currentUser != null) {
currentPlan?.displayName ?: when (currentUser!!.subscription_type) {
"free" -> "免费体验版"
"premium" -> "高级"
"professional" -> "专业"
"pro" -> "Pro "
"pro_plus" -> "Pro+ "
"premium" -> "Pro 版"
else -> "免费体验版"
}
} else null,
@@ -206,8 +187,8 @@ fun ProfileScreen(
SettingItem(
icon = AppIcons.Upgrade,
label = "升级套餐",
description = "解锁完整导出与更多功能",
onPress = { showUpgradePlanDialog = true }
description = "解锁更多对话与章节额度",
onPress = { navController?.navigate(Screen.UpgradePlan.route) }
)
SettingItem(
icon = AppIcons.Receipt,