From e8e5fcb82b942ba82a90726fbb368faaf8741e97 Mon Sep 17 00:00:00 2001 From: iammm0 Date: Tue, 10 Feb 2026 14:24:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=9B=9E=E5=BF=86=E3=80=81=E4=B8=AA=E4=BA=BA=E8=B5=84?= =?UTF-8?q?=E6=96=99=E5=8F=8A=E8=AF=AD=E9=9F=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化CreateMemoryScreen、ProfileScreen - 优化ChatInputField、VoiceRecordButton - 新增ErrorDebugPanel调试面板 Co-authored-by: Cursor --- .../ui/components/chat/ChatInputField.kt | 2 + .../ui/components/chat/VoiceRecordButton.kt | 142 ++++++------------ .../ui/components/debug/ErrorDebugPanel.kt | 134 +++++++++++++++++ .../ui/screens/CreateMemoryScreen.kt | 26 +++- .../life_echo/ui/screens/ProfileScreen.kt | 29 +--- 5 files changed, 213 insertions(+), 120 deletions(-) create mode 100644 app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/ErrorDebugPanel.kt diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt index bd6d343..79ca951 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatInputField.kt @@ -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, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt index a7cb157..3f87321 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/VoiceRecordButton.kt @@ -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) + ) } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/ErrorDebugPanel.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/ErrorDebugPanel.kt new file mode 100644 index 0000000..b0113cb --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/debug/ErrorDebugPanel.kt @@ -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) + ) + } + } + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt index 74c005f..973d592 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/CreateMemoryScreen.kt @@ -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.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() + ) + } } } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt index 329180d..42659c4 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/ProfileScreen.kt @@ -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,