# 步骤 07:聊天页 > 实现聊天页:消息列表、发送消息、消息归档。 > 对应流程:`docs/流程/04_文本消息.md` > 依赖步骤:06(频道切换) --- ## 一、目标 - [ ] ChatScreen 页面布局(头部、消息列表、输入区、语音控制栏) - [ ] 消息列表组件(区分自己/他人消息、自动滚动) - [ ] 消息输入区与键盘联动(随键盘上抬、空消息禁用发送) - [ ] SendTextMessage 流程(状态机:idle → sending → idle/failed) - [ ] OnTextMessage 消息归档(⑤ 按 TargetMode + Target 归档) - [ ] 未读消息状态管理(弱未读红点、强未读 badge、进入频道清除) --- ## 二、任务清单 ### 7.1 页面布局 **目标**:实现 ChatScreen 的三段式布局——头部、消息列表、底部(输入区 + 语音控制栏)。 **前置条件**: - 步骤 05 的 ChannelListScreen 已实现 - 步骤 06 的频道切换已完成,可通过当前频道栏跳转到聊天页 **对应 UI 设计**:`docs/UI架构设计.md` 2.3 聊天页布局 **任务**: 1. **ChatScreen 整体布局** ```kotlin // ui/screens/ChatScreen.kt @Composable fun ChatScreen( chatViewModel: ChatViewModel, channelViewModel: ChannelViewModel, voiceViewModel: VoiceViewModel, onNavigateBack: () -> Unit, onOpenChannelDetail: (channelId: Long) -> Unit ) { val messages by chatViewModel.messages.collectAsState() val currentChannel by channelViewModel.currentChannel.collectAsState() val sendMessageState by chatViewModel.sendState.collectAsState() Column(modifier = Modifier.fillMaxSize()) { // 头部:返回按钮 + 频道名 + 频道详情入口 ChatHeader( channelName = currentChannel?.name ?: "", memberCount = currentChannel?.memberCount ?: 0, onBack = onNavigateBack, onChannelDetail = { currentChannel?.let { onOpenChannelDetail(it.id) } } ) // 中部:消息列表(占满剩余空间) Box(modifier = Modifier.weight(1f)) { MessageList( messages = messages, modifier = Modifier.fillMaxSize() ) } // 中部-底部:消息输入区(随键盘上抬) MessageInputBar( sendState = sendMessageState, onSendMessage = { text -> chatViewModel.sendMessage(text) }, modifier = Modifier.fillMaxWidth() ) // 底部:语音控制栏(固定在底部,不受键盘影响) VoiceControlBar( voiceViewModel = voiceViewModel, modifier = Modifier.fillMaxWidth() ) } } ``` 2. **ChatHeader 头部组件** ```kotlin // ui/components/ChatHeader.kt @Composable fun ChatHeader( channelName: String, memberCount: Int, onBack: () -> Unit, onChannelDetail: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { // 左侧:返回按钮 IconButton(onClick = onBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回" ) } // 中部:频道名 + 人数 Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = channelName, style = MaterialTheme.typography.titleMedium ) Text( text = "${memberCount}人", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } // 右侧:频道详情入口 IconButton(onClick = onChannelDetail) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = "频道详情" ) } } } ``` ### 7.2 消息列表组件 **目标**:实现消息列表渲染,区分自己/他人消息,支持自动滚动和历史浏览。 **对应 UI 设计**:`docs/UI架构设计.md` 2.3 消息显示格式 **任务**: 1. **ChatMessage 数据模型** ```kotlin // data/Models.kt /** * 聊天消息实体 * 权威来源:OnTextMessage(对应 notifytextmessage) */ data class ChatMessage( val id: String, // 唯一标识(用于列表 key) val targetMode: Int, // 1=私聊, 2=频道, 3=服务器 val targetId: Long, // 目标 ID(频道 ID 或客户端 ID) val senderId: Int, // 发送者客户端 ID val senderName: String, // 发送者昵称 val content: String, // 消息内容 val timestamp: Long, // 消息时间戳 val isSelf: Boolean // 是否是自己发送的 ) ``` 2. **MessageList 消息列表** ```kotlin // ui/components/MessageList.kt @Composable fun MessageList( messages: List, modifier: Modifier = Modifier ) { val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() // 是否在底部(用于判断是否自动滚动) val isAtBottom by remember { derivedStateOf { val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull() lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2 } } // 新消息到达时自动滚动到底部 LaunchedEffect(messages.size) { if (isAtBottom || messages.isNotEmpty()) { listState.animateScrollToItem(messages.size - 1) } } LazyColumn( state = listState, modifier = modifier.padding(horizontal = 8.dp), contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items( items = messages, key = { it.id } ) { message -> MessageItem(message = message) } } // 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条 if (!isAtBottom && messages.isNotEmpty()) { Box( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), contentAlignment = Alignment.Center ) { FilledTonalButton( onClick = { coroutineScope.launch { listState.animateScrollToItem(messages.size - 1) } } ) { Icon(Icons.Default.ArrowDropDown, contentDescription = null) Spacer(Modifier.width(4.dp)) Text("新消息") } } } } ``` 3. **MessageItem 单条消息** ```kotlin // ui/components/MessageItem.kt @Composable fun MessageItem(message: ChatMessage) { val timeText = remember(message.timestamp) { SimpleDateFormat("HH:mm", Locale.getDefault()) .format(Date(message.timestamp)) } Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), horizontalAlignment = if (message.isSelf) Alignment.End else Alignment.Start ) { // 发送者名称 + 时间 Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = if (message.isSelf) Arrangement.End else Arrangement.Start, modifier = Modifier.fillMaxWidth() ) { if (!message.isSelf) { Text( text = message.senderName, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary ) Spacer(Modifier.width(8.dp)) } Text( text = timeText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) if (message.isSelf) { Spacer(Modifier.width(8.dp)) Text( text = message.senderName, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary ) } } Spacer(Modifier.height(2.dp)) // 消息气泡 Surface( shape = RoundedCornerShape(12.dp), color = if (message.isSelf) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant }, modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) ) } } } ``` 4. **消息长按操作菜单** ```kotlin // ui/components/MessageContextMenu.kt @Composable fun MessageContextMenu( message: ChatMessage, onCopy: () -> Unit, onPokeSender: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, title = { Text(message.senderName) }, text = { Column { DropdownMenuItem( text = { Text("📋 复制") }, onClick = { onCopy() onDismiss() } ) DropdownMenuItem( text = { Text("🫴 Poke") }, onClick = { onPokeSender() onDismiss() } ) } }, confirmButton = {}, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } } ) } ``` ### 7.3 消息输入区 **目标**:实现消息输入框和发送按钮,支持键盘联动。 **对应 UI 设计**:`docs/UI架构设计.md` 2.3 消息输入区与键盘联动 **任务**: ```kotlin // ui/components/MessageInputBar.kt @Composable fun MessageInputBar( sendState: MessageSendState, onSendMessage: (String) -> Unit, modifier: Modifier = Modifier ) { var inputText by remember { mutableStateOf("") } // 发送成功后清空输入框 LaunchedEffect(sendState) { if (sendState is MessageSendState.Idle && sendState.justSent) { inputText = "" } } Row( modifier = modifier .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // 输入框 OutlinedTextField( value = inputText, onValueChange = { inputText = it }, modifier = Modifier.weight(1f), placeholder = { Text( "输入消息...", color = MaterialTheme.colorScheme.onSurfaceVariant ) }, maxLines = 4, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Send ), keyboardActions = KeyboardActions( onSend = { if (inputText.isNotBlank()) { onSendMessage(inputText.trim()) } } ) ) Spacer(Modifier.width(8.dp)) // 发送按钮(输入框为空时置灰不可点击) IconButton( onClick = { onSendMessage(inputText.trim()) }, enabled = inputText.isNotBlank() && sendState !is MessageSendState.Sending ) { Icon( imageVector = Icons.Default.Send, contentDescription = "发送", tint = if (inputText.isNotBlank()) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant } ) } } } ``` **键盘联动要求**: - 点击输入框 → 唤起输入法 → 输入区 + 发送按钮随键盘上抬 - 语音控制栏保持固定在底部,不受键盘影响 - 使用 `WindowCompat.setDecorFitsSystemWindows(window, false)` + `imePadding()` 实现 ### 7.4 发送消息流程 **目标**:实现 SendTextMessage 的完整流程,包含状态管理和错误处理。 **对应流程**:`docs/流程/04_文本消息.md` 时序图 **关键原则**: - `SendTextMessage` 返回 `nil` 只表示服务器接受请求 - 服务端会向自己回推 `notifytextmessage`,用 `OnTextMessage` 归档权威消息 - 消息归档必须依据 `TargetMode` 和 `Target`,不能用当前页面频道代替真实目标 **任务**: 1. **发送状态定义** ```kotlin // data/Models.kt /** * 消息发送状态机 * 对应 docs/流程/04_文本消息.md 中的聊天状态 * * 状态转换: * Idle → Sending:用户提交发送意图 * Sending → Idle:命令成功(等待 OnTextMessage 归档) * Sending → Failed:命令被拒绝 * Failed → Sending:用户重试 */ sealed class MessageSendState { /** 空闲,可以发送新消息 */ data class Idle(val justSent: Boolean = false) : MessageSendState() /** 正在发送 SendTextMessage 等待响应 */ object Sending : MessageSendState() /** 发送失败 */ data class Failed(val error: String) : MessageSendState() } ``` 2. **ChatViewModel 发送逻辑** ```kotlin // viewmodel/ChatViewModel.kt class ChatViewModel( private val repository: Repository, private val application: Application ) : ViewModel() { companion object { private const val TAG = "ChatViewModel" } // 当前频道的消息列表 private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages // 发送状态 private val _sendState = MutableStateFlow(MessageSendState.Idle()) val sendState: StateFlow = _sendState // 当前查看的会话(用于筛选消息) private var currentTargetMode: Int = 2 // 默认频道消息 private var currentTargetId: Long = 0 /** * 切换当前查看的会话 * 进入聊天页时调用,加载对应频道的消息 */ fun enterChat(targetMode: Int, targetId: Long) { currentTargetMode = targetMode currentTargetId = targetId // 从归档中加载消息 _messages.value = repository.getMessages(targetMode, targetId) // 清除该频道的未读标记 repository.clearUnread(targetMode, targetId) } /** * 发送文本消息 * 对应 docs/流程/04_文本消息.md 时序图 */ fun sendMessage(text: String) { if (text.isBlank()) return if (_sendState.value is MessageSendState.Sending) { Log.w(TAG, "Message send already in progress") return } viewModelScope.launch { _sendState.value = MessageSendState.Sending try { // 通过 TSBridge 发送消息 // targetMode: 2=频道, targetId=当前频道ID val error = TSBridge.sendTextMessage( currentTargetMode, currentTargetId.toString(), text ) if (error.isEmpty()) { // 命令成功:服务器接受发送请求 // 服务端会回推 OnTextMessage,由 handleTextMessage 归档 Log.d(TAG, "SendTextMessage accepted by server") _sendState.value = MessageSendState.Idle(justSent = true) } else { // 命令被拒绝 Log.w(TAG, "SendTextMessage rejected: $error") _sendState.value = MessageSendState.Failed(mapSendError(error)) } } catch (e: Exception) { Log.e(TAG, "SendTextMessage failed", e) _sendState.value = MessageSendState.Failed("发送失败:${e.message}") } } } /** * 重试发送(失败后) */ fun retrySend(text: String) { _sendState.value = MessageSendState.Idle() sendMessage(text) } /** * 映射发送错误为用户友好提示 */ private fun mapSendError(error: String): String { return when { error.contains("permission", ignoreCase = true) -> "权限不足,无法发送消息" error.contains("flood", ignoreCase = true) -> "发送过于频繁,请稍后再试" error.contains("empty", ignoreCase = true) -> "消息内容不能为空" else -> "发送失败:$error" } } } ``` ### 7.5 消息归档逻辑 **目标**:实现 OnTextMessage 事件处理,按 TargetMode + Target 归档消息。 **对应流程**:`docs/流程/08_状态同步.md` ⑤ 消息归档同步 **关键原则**: - 消息归档必须依据 `TargetMode` 和 `Target`,不能使用当前页面频道猜测消息目标 - 非当前频道的消息仅显示弱未读提示 - 当前频道的消息直接追加到列表并自动滚动 **任务**: 1. **Repository 消息归档** ```kotlin // data/Repository.kt class Repository { // ... 已有实现 ... // 消息归档:按 (TargetMode, Target) 分组存储 // key = "${targetMode}_${targetId}" private val messageArchives = ConcurrentHashMap>() // 未读状态:key = "${targetMode}_${targetId}", value = 未读数 private val _unreadCounts = MutableStateFlow>(emptyMap()) val unreadCounts: StateFlow> = _unreadCounts // 当前查看的会话(用于判断是否需要未读提示) private var activeChatKey: String? = null /** * 归档消息(由 OnTextMessage 调用) * 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步 * * 必须读取 TargetMode 和 Target,不能使用当前页面猜测目标 */ fun archiveMessage(message: ChatMessage) { val key = "${message.targetMode}_${message.targetId}" // 归档到对应会话 val archive = messageArchives.getOrPut(key) { mutableListOf() } synchronized(archive) { archive.add(message) // 限制每个会话最多保留 500 条消息 if (archive.size > 500) { archive.removeAt(0) } } // 如果不是当前查看的会话,增加未读计数 if (key != activeChatKey) { val currentCounts = _unreadCounts.value.toMutableMap() currentCounts[key] = (currentCounts[key] ?: 0) + 1 _unreadCounts.value = currentCounts } // 如果是当前查看的会话,更新消息列表 if (key == activeChatKey) { // 触发 UI 更新 notifyMessagesChanged(message.targetMode, message.targetId) } } /** * 获取指定会话的消息列表 */ fun getMessages(targetMode: Int, targetId: Long): List { val key = "${targetMode}_${targetId}" val archive = messageArchives[key] ?: emptyList() return synchronized(archive) { archive.toList() } } /** * 设置当前活跃会话(进入聊天页时调用) */ fun setActiveChat(targetMode: Int?, targetId: Long?) { activeChatKey = if (targetMode != null && targetId != null) { "${targetMode}_${targetId}" } else { null } } /** * 清除指定会话的未读标记 */ fun clearUnread(targetMode: Int, targetId: Long) { val key = "${targetMode}_${targetId}" val currentCounts = _unreadCounts.value.toMutableMap() currentCounts.remove(key) _unreadCounts.value = currentCounts } } ``` 2. **ChatViewModel 消息接收处理** ```kotlin // viewmodel/ChatViewModel.kt /** * 处理收到的文本消息 * 由 ServerViewModel 的 OnTextMessage 回调触发 * * 对应 docs/流程/04_文本消息.md: * "OnTextMessage 归档:按 TargetMode 与 Target 归档 * 不能使用当前页面频道代替真实目标" */ fun handleTextMessage( targetMode: Int, targetId: Long, senderId: Int, senderName: String, content: String ) { val selfId = repository.selfClientId.value val message = ChatMessage( id = "${System.currentTimeMillis()}_${senderId}", targetMode = targetMode, targetId = targetId, senderId = senderId, senderName = senderName, content = content, timestamp = System.currentTimeMillis(), isSelf = senderId == selfId ) // 归档消息(Repository 内部处理未读计数) repository.archiveMessage(message) Log.d(TAG, "Message archived: mode=$targetMode, target=$targetId, from=$senderName") } /** * 离开聊天页时清理 */ fun leaveChat() { repository.setActiveChat(null, null) } ``` 3. **ServerViewModel 注册 OnTextMessage 回调** ```kotlin // ServerViewModel.kt - 在 registerEventHandlers 中添加 fun registerEventHandlers() { TSBridge.setCallbacks(object : TSBridge.Callbacks { // ... 已有回调 ... override fun onTextMessage( targetMode: Int, targetId: String, invokerId: Int, invokerName: String, message: String ) { val targetIdLong = targetId.toLongOrNull() ?: return chatViewModel.handleTextMessage( targetMode = targetMode, targetId = targetIdLong, senderId = invokerId, senderName = invokerName, content = message ) } // ... 其他回调 ... }) } ``` ### 7.6 ChatViewModel 完整实现 **目标**:整合以上各部分,实现完整的 ChatViewModel。 ```kotlin // viewmodel/ChatViewModel.kt package com.tsmobile.app.viewmodel import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.tsmobile.app.TSBridge import com.tsmobile.app.data.ChatMessage import com.tsmobile.app.data.MessageSendState import com.tsmobile.app.data.Repository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ChatViewModel( private val repository: Repository, private val application: Application ) : AndroidViewModel(application) { companion object { private const val TAG = "ChatViewModel" private const val MAX_MESSAGES_PER_CHAT = 500 } // 当前会话的消息列表 private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages // 发送状态 private val _sendState = MutableStateFlow(MessageSendState.Idle()) val sendState: StateFlow = _sendState // 当前查看的会话参数 private var currentTargetMode: Int = 2 private var currentTargetId: Long = 0 /** * 进入聊天页 * 加载消息并清除未读标记 */ fun enterChat(targetMode: Int, targetId: Long) { currentTargetMode = targetMode currentTargetId = targetId // 设置活跃会话(影响未读计数) repository.setActiveChat(targetMode, targetId) // 加载归档消息 _messages.value = repository.getMessages(targetMode, targetId) // 清除该会话的未读标记 repository.clearUnread(targetMode, targetId) Log.d(TAG, "Entered chat: mode=$targetMode, target=$targetId, messages=${_messages.value.size}") } /** * 离开聊天页 */ fun leaveChat() { repository.setActiveChat(null, null) _sendState.value = MessageSendState.Idle() Log.d(TAG, "Left chat") } /** * 发送文本消息 * 对应 docs/流程/04_文本消息.md 时序图 */ fun sendMessage(text: String) { if (text.isBlank()) return if (_sendState.value is MessageSendState.Sending) { Log.w(TAG, "Send already in progress") return } viewModelScope.launch { _sendState.value = MessageSendState.Sending try { val error = TSBridge.sendTextMessage( currentTargetMode, currentTargetId.toString(), text ) if (error.isEmpty()) { Log.d(TAG, "SendTextMessage accepted") _sendState.value = MessageSendState.Idle(justSent = true) } else { Log.w(TAG, "SendTextMessage rejected: $error") _sendState.value = MessageSendState.Failed(mapSendError(error)) } } catch (e: Exception) { Log.e(TAG, "SendTextMessage exception", e) _sendState.value = MessageSendState.Failed("发送失败:${e.message}") } } } /** * 处理收到的文本消息(由 ServerViewModel 调用) * 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步 */ fun handleTextMessage( targetMode: Int, targetId: Long, senderId: Int, senderName: String, content: String ) { val selfId = repository.selfClientId.value val message = ChatMessage( id = "${System.currentTimeMillis()}_${senderId}", targetMode = targetMode, targetId = targetId, senderId = senderId, senderName = senderName, content = content, timestamp = System.currentTimeMillis(), isSelf = senderId == selfId ) repository.archiveMessage(message) // 如果是当前查看的会话,更新消息列表 if (targetMode == currentTargetMode && targetId == currentTargetId) { _messages.value = repository.getMessages(targetMode, targetId) } } /** * 重试发送 */ fun retrySend(text: String) { _sendState.value = MessageSendState.Idle() sendMessage(text) } /** * 清除发送状态(用于 UI 重置) */ fun clearSendState() { _sendState.value = MessageSendState.Idle() } private fun mapSendError(error: String): String { return when { error.contains("permission", ignoreCase = true) -> "权限不足" error.contains("flood", ignoreCase = true) -> "发送过于频繁" error.contains("empty", ignoreCase = true) -> "消息不能为空" else -> "发送失败:$error" } } } ``` --- ## 三、状态与数据流 ### 3.1 消息发送状态机 ``` ┌─────────────────────────────────┐ │ │ ▼ │ ┌─────────┐ │ │ Idle │◄──────────────────────────┤ └────┬────┘ │ │ 用户点击发送 │ ▼ │ ┌──────────┐ │ │ Sending │ │ └────┬─────┘ │ │ │ ┌─────────┴──────────┐ │ │ │ │ ▼ ▼ │ error="" error! │ │ │ │ ▼ ▼ │ Idle(justSent) Failed │ │ │ │ │ 用户重试 │ │ │ │ └────────────────────┴─────────────────────┘ ``` ### 3.2 消息归档数据流 ``` 服务端 SDK Repository ChatViewModel UI │ │ │ │ │ │ notifytextmessage │ │ │ │ ├────────────────────→│ │ │ │ │ │ OnTextMessage │ │ │ │ ├───────────────────────→│ │ │ │ │ │ │ │ │ │ │ 读取 TargetMode │ │ │ │ │ + Target │ │ │ │ │ │ │ │ │ │ 归档到对应会话 │ │ │ │ │ │ │ │ │ │ 当前会话? │ │ │ │ ├─ 是 ─────────────────→│ 更新消息列表 │ │ │ │ ├─────────────────→│ │ │ │ │ │ │ │ ├─ 否 ─→ 增加未读计数 │ │ │ │ │ │ │ ``` ### 3.3 命令响应与事件事实的区分 **关键原则**(对应 `docs/流程/04_文本消息.md`): | 概念 | 含义 | 处理方式 | |------|------|----------| | SendTextMessage 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed | | SendTextMessage 返回 nil | 命令被服务器接受 | 状态 → Idle(justSent=true) | | OnTextMessage(self) | 服务端回推自己的消息 | 归档到对应会话,更新列表 | **为什么发送成功后要等 OnTextMessage 归档?** - `SendTextMessage` 返回 nil 只表示服务器接受了请求 - 服务端会向自己回推 `notifytextmessage` - 这条回推消息包含服务器处理后的完整信息(如时间戳) - 用 `OnTextMessage` 归档确保消息列表的一致性 --- ## 四、未读消息管理 ### 4.1 未读状态定义 | 状态 | 显示 | 触发条件 | 清除条件 | |------|------|----------|----------| | 无未读 | 正常显示 | — | — | | 弱未读 | 频道名右侧红点 ● | 非当前频道收到普通消息 | 用户进入该频道 | | 强未读 | 频道名右侧数字 badge | 非当前频道收到 @提及 | 用户进入该频道 | | 当前频道 | 不显示未读 | 用户正在查看该频道 | — | ### 4.2 未读状态数据流 ```kotlin // ChannelListScreen.kt - 在频道树中显示未读状态 @Composable fun ChannelRow( channel: ChannelInfo, unreadCount: Int, isCurrentChannel: Boolean, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { // ... 频道名 ... Spacer(Modifier.weight(1f)) // 未读指示(当前频道不显示) if (!isCurrentChannel && unreadCount > 0) { if (unreadCount > 99) { // 强未读:显示数字 Badge { Text("99+") } } else if (unreadCount > 1) { // 强未读:显示数字 Badge { Text("$unreadCount") } } else { // 弱未读:显示红点 Box( modifier = Modifier .size(8.dp) .background( color = MaterialTheme.colorScheme.error, shape = CircleShape ) ) } } } } ``` --- ## 五、验收标准 ### 功能验收 - [ ] **页面布局** - 头部显示频道名、人数、返回按钮、频道详情入口 - 消息列表占满中间区域,支持滚动 - 输入区在底部,随键盘上抬 - 语音控制栏固定在最底部 - [ ] **消息显示** - 自己的消息靠右对齐,使用不同背景色 - 他人的消息靠左对齐,显示发送者名称 - 每条消息显示发送时间(HH:mm 格式) - 新消息到达时自动滚动到底部 - 查看历史消息时显示 "↓ 新消息" 提示条 - [ ] **发送消息** - 输入框为空时发送按钮置灰 - 点击发送或键盘回车触发发送 - 发送中显示加载状态 - 发送成功清空输入框,保持键盘打开 - 发送失败显示错误提示,可重试 - [ ] **消息归档** - 收到 OnTextMessage 按 TargetMode + Target 归档 - 当前频道消息直接追加到列表 - 非当前频道消息增加未读计数 - 进入频道时清除未读标记 - [ ] **未读指示** - 频道列表页显示弱未读红点(1条新消息) - 频道列表页显示强未读数字(多条新消息) - 当前所在频道不显示未读标记 ### 错误处理验收 | 错误场景 | 预期行为 | |----------|----------| | 权限不足 | 提示"权限不足,无法发送消息" | | 发送过于频繁 | 提示"发送过于频繁,请稍后再试" | | 网络断开 | 提示"发送失败",可重试 | | 消息内容为空 | 发送按钮禁用 | ### 性能验收 - [ ] 消息列表滚动流畅(500 条消息) - [ ] 新消息到达自动滚动无卡顿 - [ ] 输入框输入响应 < 50ms ### 测试用例 | 场景 | 操作 | 预期结果 | |------|------|----------| | 发送频道消息 | 输入文本 → 点击发送 | 消息出现在列表右侧 | | 接收他人消息 | 他人发送消息 | 消息出现在列表左侧,显示发送者名 | | 自动滚动 | 收到新消息 | 列表自动滚动到底部 | | 查看历史 | 向上滚动查看历史 | 显示"↓ 新消息"提示条 | | 未读指示 | 切换到其他频道收到消息 | 频道名显示红点 | | 清除未读 | 进入有未读的频道 | 红点消失 | | 发送失败 | 断网后发送 | 显示错误提示,可重试 | | 空消息 | 输入框为空 | 发送按钮置灰 | | 长按消息 | 长按消息气泡 | 弹出操作菜单(复制/Poke) | --- ## 六、参考文档 - `docs/流程/04_文本消息.md` - 时序图、状态机、事件依赖 - `docs/流程/08_状态同步.md` - ⑤ 消息归档同步 - `docs/UI架构设计.md` - 2.3 聊天页布局、5.5 未读消息指示 - `docs/sdk文档-go.md` - SendTextMessage API、OnTextMessage 事件、TextMessage 结构 - `docs/implementation/02_Bridge层实现.md` - TSBridge.sendTextMessage、onTextMessage 回调 - `docs/implementation/05_频道列表页.md` - ChannelListScreen 未读指示集成 - `docs/implementation/06_频道切换.md` - 进入聊天页的导航入口