diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatHeader.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatHeader.kt index 953f659..940a297 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatHeader.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/chat/ChatHeader.kt @@ -32,6 +32,7 @@ import com.huaga.life_echo.ui.theme.* * @param title 标题 * @param isOnline 在线状态 * @param onBackClick 返回按钮点击回调 + * @param onNewConversationClick 新建对话按钮点击回调,为 null 时不显示该按钮 * @param modifier 修饰符 * @param isTransparent 是否透明化(默认false) * @param transparencyType 透明化类型:0=完全透明, 1=半透明, 2=渐变透明 @@ -42,6 +43,7 @@ fun ChatHeader( title: String, isOnline: Boolean = true, onBackClick: () -> Unit, + onNewConversationClick: (() -> Unit)? = null, modifier: Modifier = Modifier, isTransparent: Boolean = false, transparencyType: Int = 0, @@ -140,8 +142,23 @@ fun ChatHeader( } } - // 右侧占位,保持标题居中 - Spacer(modifier = Modifier.size(48.dp)) + // 右侧:新建对话按钮或占位,保持标题居中 + if (onNewConversationClick != null) { + IconButton( + onClick = onNewConversationClick, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(AppDimensions.buttonRadius)) + ) { + Icon( + imageVector = AppIcons.Add, + contentDescription = "新建对话", + tint = AppWhite + ) + } + } else { + Spacer(modifier = Modifier.size(48.dp)) + } } } } 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 3f87321..3bc8973 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 @@ -4,6 +4,7 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -14,11 +15,16 @@ 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.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.sp import com.huaga.life_echo.ui.icons.AppIcons import com.huaga.life_echo.ui.theme.* @@ -32,10 +38,25 @@ enum class RecordingState { CANCEL // 准备取消 } +/** + * 在录音浮层上松开时的动作:由滑动到的区域决定 + */ +enum class ReleaseAction { + /** 取消,不发送 */ + CANCEL, + /** 发送语音消息 */ + SEND_VOICE, + /** 转文字并发送文本消息 */ + SEND_AS_TEXT +} + /** * 语音录音按钮组件(仿微信按住说话) * 松开即发送:浮层仅做视觉反馈,不拦截触摸,由按钮直接接收 release 并触发发送。 * + * 关键实现:通过 consume() 消费所有 pointer 事件,防止父容器(如滚动列表) + * 偷走手势,确保按住期间能正确检测释放和上滑取消。 + * * @param onStartRecording 开始录音回调 * @param onStopRecording 停止录音并发送回调 * @param onCancelRecording 取消录音回调 @@ -56,10 +77,6 @@ fun VoiceRecordButton( 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( @@ -82,7 +99,6 @@ fun VoiceRecordButton( LaunchedEffect(isRecording) { if (!isRecording && recordingState != RecordingState.IDLE) { recordingState = RecordingState.IDLE - dragOffsetY = 0f } } // 通知上层当前录音状态(用于全屏浮层展示,浮层不消费触摸,松开由本按钮接收并立即发送) @@ -99,41 +115,26 @@ fun VoiceRecordButton( .scale(scale) .clip(RoundedCornerShape(36.dp)) .background(backgroundColor) - .pointerInput(enabled, cancelThreshold) { + .pointerInput(enabled) { if (!enabled) return@pointerInput - + // 按下即开始录音;同一 pointer 的 release 会先到本按钮,必须在此处理才能终止录音 awaitPointerEventScope { while (true) { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val changes = event.changes - - // 按下:开始录音(任一指头按下) - val justPressed = changes.any { it.pressed && !it.previousPressed } - if (justPressed) { + val justDown = changes.any { it.changedToDown() } + val justUp = changes.any { it.changedToUp() } + val allUp = changes.all { !it.pressed } + if (justDown) { + changes.forEach { it.consume() } 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 -> {} - } + } else if (recordingState == RecordingState.RECORDING && (justUp || allUp)) { + changes.forEach { it.consume() } + onStopRecording() 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 - } + } else if (recordingState == RecordingState.RECORDING) { + changes.forEach { it.consume() } } } } @@ -175,64 +176,157 @@ fun VoiceRecordButton( } /** - * 录音浮层纯视觉内容:全屏半透明遮罩 + 居中计时卡片。 - * 不处理触摸(无 pointerInput),松开由 VoiceRecordButton 接收并立即发送。 - * 供上层在根布局用 Box 展示,避免 Popup 拦截导致需二次点击才发送。 + * 录音浮层:按住说话后覆盖在聊天之上的全屏层,只检测本层手势。 + * - 按住底部按钮维持录音,浮层出现后底层不再响应操作。 + * - 滑动到「取消」松开 = 不发送;滑动到「转文字」松开 = 转成文字并发送;否则松开发送语音。 + * + * @param onRelease 手指抬起时回调,根据当前滑入的区域传入对应动作 */ @Composable fun RecordingOverlayContent( - state: RecordingState, duration: Int, + onRelease: (ReleaseAction) -> Unit = {}, modifier: Modifier = Modifier ) { - Box( - modifier = modifier.background(Color.Black.copy(alpha = 0.3f)), - contentAlignment = Alignment.Center - ) { + var highlightZone by remember { mutableStateOf(null) } + + BoxWithConstraints(modifier = modifier) { + val widthPx = with(LocalDensity.current) { maxWidth.toPx() } 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 + .fillMaxSize() + .then( + Modifier.pointerInput(widthPx) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + val w = widthPx + val position = event.changes.firstOrNull()?.position ?: Offset.Zero + if (w > 0) { + val third = w / 3f + highlightZone = when { + position.x < third -> ReleaseAction.CANCEL + position.x > third * 2f -> ReleaseAction.SEND_AS_TEXT + else -> ReleaseAction.SEND_VOICE + } + } + if (event.changes.any { it.changedToUp() }) { + val action = highlightZone ?: ReleaseAction.SEND_VOICE + highlightZone = null + onRelease(action) + break + } + } + } + } + ) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Spacer(modifier = Modifier.weight(0.4f)) + // 中部:计时 + 波形 + 提示 + Box( + modifier = Modifier + .size(200.dp) + .clip(RoundedCornerShape(20.dp)) + .background(DeepPurple.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center ) { - if (state == RecordingState.CANCEL) { - Icon( - imageVector = AppIcons.Close, - contentDescription = null, - tint = AppWhite, - modifier = Modifier.size(48.dp) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + RecordingIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = formatDuration(duration), + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + color = AppWhite ) - } else { - RecordingIndicator( - modifier = Modifier.size(48.dp) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "左滑取消 · 右滑转文字", + fontSize = 13.sp, + color = AppWhite.copy(alpha = 0.85f) ) } - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = formatDuration(duration), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = AppWhite + } + Spacer(modifier = Modifier.weight(0.2f)) + // 底部三区:取消 | 松开发送 | 转文字(需按住底部按钮维持 = 本层即“底部按钮”的延伸) + Row( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .background(Color.Black.copy(alpha = 0.35f)), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + ZoneChip( + label = "取消", + icon = AppIcons.Close, + isHighlight = highlightZone == ReleaseAction.CANCEL, + tintHighlight = ErrorRed ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (state == RecordingState.CANCEL) "松开取消" else "上滑取消", - fontSize = 13.sp, - color = AppWhite.copy(alpha = 0.8f) + ZoneChip( + label = "松开发送", + icon = AppIcons.Mic, + isHighlight = highlightZone == ReleaseAction.SEND_VOICE, + tintHighlight = MediumPurple + ) + ZoneChip( + label = "转文字", + icon = AppIcons.Keyboard, + isHighlight = highlightZone == ReleaseAction.SEND_AS_TEXT, + tintHighlight = Lavender ) } } } + } +} + +@Composable +private fun ZoneChip( + label: String, + icon: ImageVector, + isHighlight: Boolean, + tintHighlight: Color +) { + val bg = if (isHighlight) tintHighlight.copy(alpha = 0.35f) else Color.Transparent + val tint = if (isHighlight) tintHighlight else AppWhite.copy(alpha = 0.9f) + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(bg) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = tint + ) + } + } } /**