1105 lines
37 KiB
Markdown
1105 lines
37 KiB
Markdown
# 步骤 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<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("新消息")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
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<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`,不能使用当前页面频道猜测消息目标
|
|||
|
|
- 非当前频道的消息仅显示弱未读提示
|
|||
|
|
- 当前频道的消息直接追加到列表并自动滚动
|
|||
|
|
|
|||
|
|
**任务**:
|
|||
|
|
|
|||
|
|
1. **Repository 消息归档**
|
|||
|
|
```kotlin
|
|||
|
|
// 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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
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<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 未读状态数据流
|
|||
|
|
|
|||
|
|
```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` - 进入聊天页的导航入口
|