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` - 进入聊天页的导航入口
|