37 KiB
37 KiB
步骤 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 聊天页布局
任务:
-
ChatScreen 整体布局
// 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() ) } } -
ChatHeader 头部组件
// 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 消息显示格式
任务:
-
ChatMessage 数据模型
// 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 // 是否是自己发送的 ) -
MessageList 消息列表
// ui/components/MessageList.kt @Composable fun MessageList( messages: List<ChatMessage>, 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("新消息") } } } } -
MessageItem 单条消息
// 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) ) } } } -
消息长按操作菜单
// 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 消息输入区与键盘联动
任务:
// 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,不能用当前页面频道代替真实目标
任务:
-
发送状态定义
// 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() } -
ChatViewModel 发送逻辑
// 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<List<ChatMessage>>(emptyList()) val messages: StateFlow<List<ChatMessage>> = _messages // 发送状态 private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle()) val sendState: StateFlow<MessageSendState> = _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,不能使用当前页面频道猜测消息目标 - 非当前频道的消息仅显示弱未读提示
- 当前频道的消息直接追加到列表并自动滚动
任务:
-
Repository 消息归档
// data/Repository.kt class Repository { // ... 已有实现 ... // 消息归档:按 (TargetMode, Target) 分组存储 // key = "${targetMode}_${targetId}" private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>() // 未读状态:key = "${targetMode}_${targetId}", value = 未读数 private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap()) val unreadCounts: StateFlow<Map<String, Int>> = _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<ChatMessage> { 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 } } -
ChatViewModel 消息接收处理
// 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) } -
ServerViewModel 注册 OnTextMessage 回调
// 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。
// 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<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
// 发送状态
private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle())
val sendState: StateFlow<MessageSendState> = _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 未读状态数据流
// 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- 进入聊天页的导航入口