refactor: 优化创建回忆、个人资料及语音组件
- 优化CreateMemoryScreen、ProfileScreen - 优化ChatInputField、VoiceRecordButton - 新增ErrorDebugPanel调试面板 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user