1081 lines
38 KiB
Markdown
1081 lines
38 KiB
Markdown
# 步骤 05:频道列表页
|
||
|
||
> 实现频道列表页,包括频道树渲染、成员显示、未读指示、当前频道栏和 ChannelViewModel。
|
||
|
||
---
|
||
|
||
## 一、目标
|
||
|
||
- [ ] ChannelListScreen 页面布局(头部 + 频道树 + 当前频道栏 + 底部语音控制)
|
||
- [ ] 频道树组件(展开/折叠、层级缩进、状态图标)
|
||
- [ ] 成员列表显示(按频道分组、在线状态)
|
||
- [ ] 当前频道栏(显示当前频道名、人数、成员预览)
|
||
- [ ] 底部语音控制区(基础:静音按钮 + PTT + 展开入口)
|
||
- [ ] 未读消息指示(红点、@提及 badge)
|
||
- [ ] ChannelViewModel 实现(状态管理、增量同步、补偿同步)
|
||
|
||
---
|
||
|
||
## 二、任务清单
|
||
|
||
### 5.1 页面布局
|
||
|
||
**目标**:实现频道列表页的整体三段式布局。
|
||
|
||
**布局结构**:
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ 头部(三段) │
|
||
│ ┌────┐ ┌──────────────────┐ ┌─────┐ │
|
||
│ │ ☰ │ │ MyServer │ │ 🟢 │ │
|
||
│ │服务│ │ 192.168.1.1:9987 │ │连接 │ │ ← 连接状态指示
|
||
│ │器卡│ │ 42人在线 │ │状态 │ │
|
||
│ └────┘ └──────────────────┘ └─────┘ │
|
||
├──────────────────────────────────────┤
|
||
│ 中部:频道树 + 成员 │
|
||
│ │
|
||
│ ▼ 📁 默认频道 │ ← 展开/折叠
|
||
│ ▼ 📁 大厅 │
|
||
│ 👤 Alice │ ← 点击弹出成员操作菜单
|
||
│ 👤 Bob 🔇 │
|
||
│ 👤 Charlie 🎤 │
|
||
│ ▶ 📁 游戏区 🔒 │ ← 🔒 = 有密码
|
||
│ (3人) │ ← 折叠时显示人数
|
||
│ ▶ 📁 音乐区 │
|
||
│ (1人) │
|
||
│ ▼ 📁 VIP 频道 🔒 │
|
||
│ 👤 Admin │
|
||
│ │
|
||
│ (频道列表支持滚动) │
|
||
├──────────────────────────────────────┤
|
||
│ 中部-底部:当前频道栏 │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 💬 大厅 (5人) Alice🎤 Bob │ │ ← 点击跳转到聊天页
|
||
│ └──────────────────────────────────┘ │
|
||
├──────────────────────────────────────┤
|
||
│ 底部:语音控制 │
|
||
│ ┌──────┐ ┌────────────────┐ ┌─────┐ │
|
||
│ │ 🎤 │ │ PTT 按住发言 │ │ ⬆ │ │
|
||
│ │静音 │ │ │ │语音 │ │ ← 展开语音卡
|
||
│ └──────┘ └────────────────┘ └─────┘ │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
**任务**:
|
||
|
||
1. **创建 ChannelListScreen.kt**
|
||
```kotlin
|
||
// ui/screens/ChannelListScreen.kt
|
||
@Composable
|
||
fun ChannelListScreen(
|
||
channelViewModel: ChannelViewModel,
|
||
serverViewModel: ServerViewModel,
|
||
voiceViewModel: VoiceViewModel,
|
||
onNavigateToChat: () -> Unit,
|
||
onNavigateToServerConfig: () -> Unit,
|
||
onOpenServerDetail: () -> Unit,
|
||
onOpenChannelDetail: (channelId: Long) -> Unit,
|
||
onOpenVoiceCard: () -> Unit
|
||
) {
|
||
val connectionState by serverViewModel.connectionState.collectAsState()
|
||
val syncState by channelViewModel.syncState.collectAsState()
|
||
|
||
Column(modifier = Modifier.fillMaxSize()) {
|
||
// 头部
|
||
ChannelListHeader(
|
||
serverName = serverViewModel.serverName,
|
||
serverAddress = serverViewModel.serverAddress,
|
||
onlineCount = serverViewModel.onlineCount,
|
||
connectionState = connectionState,
|
||
onOpenServerDetail = onOpenServerDetail,
|
||
onOpenChannelDetail = { onOpenChannelDetail(channelViewModel.currentChannelId) }
|
||
)
|
||
|
||
// 中部:频道树或同步加载态
|
||
Box(modifier = Modifier.weight(1f)) {
|
||
when (syncState) {
|
||
SyncState.Syncing -> SyncLoadingIndicator()
|
||
SyncState.SyncFailed -> SyncFailedView(onRetry = { channelViewModel.retrySync() })
|
||
else -> ChannelTreeContent(
|
||
channelViewModel = channelViewModel,
|
||
onChannelClick = { /* 切换频道逻辑 */ },
|
||
onClientClick = { /* 成员操作菜单 */ },
|
||
onNavigateToChat = onNavigateToChat
|
||
)
|
||
}
|
||
}
|
||
|
||
// 中部-底部:当前频道栏
|
||
CurrentChannelBar(
|
||
channelViewModel = channelViewModel,
|
||
onClick = onNavigateToChat
|
||
)
|
||
|
||
// 底部:语音控制
|
||
VoiceControlBar(
|
||
voiceViewModel = voiceViewModel,
|
||
onExpand = onOpenVoiceCard
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
2. **头部组件**
|
||
```kotlin
|
||
@Composable
|
||
fun ChannelListHeader(
|
||
serverName: String,
|
||
serverAddress: String,
|
||
onlineCount: Int,
|
||
connectionState: ConnectionState,
|
||
onOpenServerDetail: () -> Unit,
|
||
onOpenChannelDetail: () -> Unit
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
// 左侧:服务器卡按钮
|
||
IconButton(onClick = onOpenServerDetail) {
|
||
Icon(Icons.Default.Menu, contentDescription = "服务器详情")
|
||
}
|
||
|
||
// 中部:服务器信息
|
||
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||
Text(serverName, style = MaterialTheme.typography.titleMedium)
|
||
Text(serverAddress, style = MaterialTheme.typography.bodySmall)
|
||
Text("${onlineCount}人在线", style = MaterialTheme.typography.bodySmall)
|
||
}
|
||
|
||
// 右侧:连接状态指示
|
||
ConnectionStatusDot(connectionState)
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun ConnectionStatusDot(state: ConnectionState) {
|
||
val color = when (state) {
|
||
ConnectionState.Ready -> Color.Green
|
||
ConnectionState.Connecting, ConnectionState.Connected, ConnectionState.Syncing -> Color.Yellow
|
||
is ConnectionState.Failed -> Color.Red
|
||
else -> Color.Gray
|
||
}
|
||
Box(
|
||
modifier = Modifier
|
||
.size(12.dp)
|
||
.background(color, CircleShape)
|
||
)
|
||
}
|
||
```
|
||
|
||
3. **同步加载态组件**
|
||
```kotlin
|
||
@Composable
|
||
fun SyncLoadingIndicator() {
|
||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||
CircularProgressIndicator()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
Text("正在同步服务器数据...")
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun SyncFailedView(onRetry: () -> Unit) {
|
||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||
Text("同步失败", color = MaterialTheme.colorScheme.error)
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
Button(onClick = onRetry) {
|
||
Text("重试")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 频道树组件
|
||
|
||
**目标**:实现可展开/折叠的频道树,支持层级缩进和状态图标。
|
||
|
||
**任务**:
|
||
|
||
1. **频道树数据模型**
|
||
```kotlin
|
||
// 频道树节点,包含子频道和成员
|
||
data class ChannelTreeNode(
|
||
val channel: ChannelInfo,
|
||
val children: List<ChannelTreeNode>,
|
||
val clients: List<ClientInfo>,
|
||
val isExpanded: Boolean = false,
|
||
val unreadCount: Int = 0,
|
||
val hasMention: Boolean = false
|
||
)
|
||
```
|
||
|
||
2. **频道树构建逻辑**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
fun buildChannelTree(
|
||
channels: List<ChannelInfo>,
|
||
clients: List<ClientInfo>,
|
||
expandedIds: Set<Long>
|
||
): List<ChannelTreeNode> {
|
||
// 按 ParentID 分组
|
||
val childrenMap = channels.groupBy { it.parentId }
|
||
|
||
// 递归构建树
|
||
fun buildNode(channel: ChannelInfo): ChannelTreeNode {
|
||
val children = (childrenMap[channel.id] ?: emptyList())
|
||
.sortedBy { it.order }
|
||
.map { buildNode(it) }
|
||
val channelClients = clients.filter { it.channelId == channel.id }
|
||
|
||
return ChannelTreeNode(
|
||
channel = channel,
|
||
children = children,
|
||
clients = channelClients,
|
||
isExpanded = expandedIds.contains(channel.id)
|
||
)
|
||
}
|
||
|
||
// 顶级频道(parentId == 0)
|
||
return (childrenMap[0L] ?: emptyList())
|
||
.sortedBy { it.order }
|
||
.map { buildNode(it) }
|
||
}
|
||
```
|
||
|
||
3. **频道树 Composable**
|
||
```kotlin
|
||
@Composable
|
||
fun ChannelTreeContent(
|
||
channelViewModel: ChannelViewModel,
|
||
onChannelClick: (ChannelInfo) -> Unit,
|
||
onClientClick: (ClientInfo) -> Unit,
|
||
onNavigateToChat: () -> Unit
|
||
) {
|
||
val tree by channelViewModel.channelTree.collectAsState()
|
||
val selfClientId by channelViewModel.selfClientId.collectAsState()
|
||
|
||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||
tree.forEach { node ->
|
||
channelTreeNodeItems(
|
||
node = node,
|
||
depth = 0,
|
||
selfClientId = selfClientId,
|
||
onChannelClick = onChannelClick,
|
||
onClientClick = onClientClick,
|
||
onToggleExpand = { channelViewModel.toggleExpand(it) }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 递归添加 LazyColumn 项
|
||
fun LazyListScope.channelTreeNodeItems(
|
||
node: ChannelTreeNode,
|
||
depth: Int,
|
||
selfClientId: Int?,
|
||
onChannelClick: (ChannelInfo) -> Unit,
|
||
onClientClick: (ClientInfo) -> Unit,
|
||
onToggleExpand: (Long) -> Unit
|
||
) {
|
||
item(key = "channel_${node.channel.id}") {
|
||
ChannelRow(
|
||
node = node,
|
||
depth = depth,
|
||
onClick = {
|
||
if (node.children.isNotEmpty()) {
|
||
onToggleExpand(node.channel.id)
|
||
} else {
|
||
onChannelClick(node.channel)
|
||
}
|
||
},
|
||
onLongClick = { /* 弹出频道操作菜单 */ }
|
||
)
|
||
}
|
||
|
||
if (node.isExpanded) {
|
||
// 显示成员
|
||
node.clients.forEach { client ->
|
||
item(key = "client_${client.id}") {
|
||
ClientRow(
|
||
client = client,
|
||
depth = depth + 1,
|
||
isSelf = client.id == selfClientId,
|
||
onClick = { onClientClick(client) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// 递归显示子频道
|
||
node.children.forEach { child ->
|
||
channelTreeNodeItems(
|
||
node = child,
|
||
depth = depth + 1,
|
||
selfClientId = selfClientId,
|
||
onChannelClick = onChannelClick,
|
||
onClientClick = onClientClick,
|
||
onToggleExpand = onToggleExpand
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **频道行组件**
|
||
```kotlin
|
||
@Composable
|
||
fun ChannelRow(
|
||
node: ChannelTreeNode,
|
||
depth: Int,
|
||
onClick: () -> Unit,
|
||
onLongClick: () -> Unit
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(start = (depth * 24).dp)
|
||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
// 展开/折叠图标
|
||
if (node.children.isNotEmpty()) {
|
||
Icon(
|
||
imageVector = if (node.isExpanded) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
|
||
contentDescription = null,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
} else {
|
||
Spacer(modifier = Modifier.size(20.dp))
|
||
}
|
||
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
|
||
// 频道图标
|
||
Icon(
|
||
imageVector = Icons.Default.Folder,
|
||
contentDescription = null,
|
||
tint = MaterialTheme.colorScheme.primary
|
||
)
|
||
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
|
||
// 频道名
|
||
Text(
|
||
text = node.channel.name,
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
|
||
// 状态图标
|
||
if (node.channel.isPassword) {
|
||
Icon(Icons.Default.Lock, contentDescription = "有密码", modifier = Modifier.size(16.dp))
|
||
}
|
||
|
||
// 未读指示
|
||
if (node.unreadCount > 0) {
|
||
Badge { Text("${node.unreadCount}") }
|
||
} else if (node.hasMention) {
|
||
Box(
|
||
modifier = Modifier
|
||
.size(8.dp)
|
||
.background(Color.Red, CircleShape)
|
||
)
|
||
}
|
||
|
||
// 折叠时显示人数
|
||
if (!node.isExpanded && node.clients.isNotEmpty()) {
|
||
Text(
|
||
text = "(${node.clients.size}人)",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
5. **成员行组件**
|
||
```kotlin
|
||
@Composable
|
||
fun ClientRow(
|
||
client: ClientInfo,
|
||
depth: Int,
|
||
isSelf: Boolean,
|
||
onClick: () -> Unit
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(start = (depth * 24).dp)
|
||
.clickable(onClick = onClick)
|
||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Spacer(modifier = Modifier.width(28.dp)) // 对齐频道行的文字
|
||
|
||
// 用户图标
|
||
Icon(
|
||
imageVector = Icons.Default.Person,
|
||
contentDescription = null,
|
||
tint = if (isSelf) MaterialTheme.colorScheme.primary
|
||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
|
||
// 昵称
|
||
Text(
|
||
text = client.nickname,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
|
||
// 服务器组图标(可选)
|
||
if (client.serverGroups.isNotEmpty()) {
|
||
Icon(
|
||
imageVector = Icons.Default.Shield,
|
||
contentDescription = "管理员",
|
||
modifier = Modifier.size(16.dp),
|
||
tint = MaterialTheme.colorScheme.tertiary
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.3 成员显示
|
||
|
||
**目标**:按频道分组显示在线成员,支持增量更新。
|
||
|
||
**任务**:
|
||
|
||
1. **成员分组索引**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
// 从 Repository 获取按频道分组的成员列表
|
||
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
|
||
```
|
||
|
||
2. **成员操作菜单**
|
||
```kotlin
|
||
@Composable
|
||
fun ClientActionMenu(
|
||
client: ClientInfo,
|
||
isSelf: Boolean,
|
||
onPoke: () -> Unit,
|
||
onCopyNickname: () -> Unit,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
AlertDialog(
|
||
onDismissRequest = onDismiss,
|
||
title = {
|
||
Column {
|
||
Text(client.nickname, style = MaterialTheme.typography.titleMedium)
|
||
Text("频道: ${getChannelName(client.channelId)}",
|
||
style = MaterialTheme.typography.bodySmall)
|
||
}
|
||
},
|
||
confirmButton = {},
|
||
dismissButton = {},
|
||
text = {
|
||
Column {
|
||
if (!isSelf) {
|
||
ListItem(
|
||
headlineContent = { Text("Poke") },
|
||
leadingContent = { Icon(Icons.Default.TouchApp, null) },
|
||
modifier = Modifier.clickable { onPoke(); onDismiss() }
|
||
)
|
||
}
|
||
ListItem(
|
||
headlineContent = { Text("复制昵称") },
|
||
leadingContent = { Icon(Icons.Default.ContentCopy, null) },
|
||
modifier = Modifier.clickable { onCopyNickname(); onDismiss() }
|
||
)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
```
|
||
|
||
### 5.4 未读指示逻辑
|
||
|
||
**目标**:实现频道未读消息和 @提及的指示。
|
||
|
||
**任务**:
|
||
|
||
1. **未读状态数据模型**
|
||
```kotlin
|
||
data class UnreadState(
|
||
val channelId: Long,
|
||
val unreadCount: Int = 0,
|
||
val hasMention: Boolean = false
|
||
)
|
||
```
|
||
|
||
2. **未读状态管理**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
|
||
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
|
||
|
||
fun onTextMessage(msg: TextMessage) {
|
||
// 只处理频道消息(TargetMode == 2)
|
||
if (msg.TargetMode != 2) return
|
||
|
||
val channelId = msg.Target.toLong()
|
||
|
||
// 如果不在该频道,更新未读状态
|
||
if (channelId != repository.selfChannelId.value) {
|
||
val current = _unreadStates.value[channelId] ?: UnreadState(channelId)
|
||
val hasMention = msg.Message.contains("@${repository.selfNickname.value}")
|
||
|
||
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
|
||
put(channelId, current.copy(
|
||
unreadCount = current.unreadCount + 1,
|
||
hasMention = current.hasMention || hasMention
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
fun clearUnread(channelId: Long) {
|
||
_unreadStates.value = _unreadStates.value.toMutableMap().apply {
|
||
remove(channelId)
|
||
}
|
||
}
|
||
```
|
||
|
||
3. **整合到频道树**
|
||
```kotlin
|
||
// buildChannelTree 时整合未读状态
|
||
fun buildChannelTree(
|
||
channels: List<ChannelInfo>,
|
||
clients: List<ClientInfo>,
|
||
expandedIds: Set<Long>,
|
||
unreadStates: Map<Long, UnreadState>
|
||
): List<ChannelTreeNode> {
|
||
// ... 构建树逻辑 ...
|
||
|
||
return ChannelTreeNode(
|
||
channel = channel,
|
||
children = children,
|
||
clients = channelClients,
|
||
isExpanded = expandedIds.contains(channel.id),
|
||
unreadCount = unreadStates[channel.id]?.unreadCount ?: 0,
|
||
hasMention = unreadStates[channel.id]?.hasMention ?: false
|
||
)
|
||
}
|
||
```
|
||
|
||
### 5.5 当前频道栏
|
||
|
||
**目标**:显示当前所在频道信息,点击跳转聊天页。
|
||
|
||
**任务**:
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun CurrentChannelBar(
|
||
channelViewModel: ChannelViewModel,
|
||
onClick: () -> Unit
|
||
) {
|
||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||
val channels by channelViewModel.channels.collectAsState()
|
||
val channelClients by channelViewModel.channelClients.collectAsState()
|
||
|
||
val currentChannel = channels.find { it.id == currentChannelId }
|
||
val clients = channelClients[currentChannelId] ?: emptyList()
|
||
|
||
if (currentChannel != null) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.clickable(onClick = onClick)
|
||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
// 频道图标和名称
|
||
Icon(Icons.Default.Chat, contentDescription = null)
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
Text(
|
||
text = "${currentChannel.name} (${clients.size}人)",
|
||
style = MaterialTheme.typography.bodyLarge,
|
||
fontWeight = FontWeight.Medium
|
||
)
|
||
|
||
Spacer(modifier = Modifier.weight(1f))
|
||
|
||
// 成员预览(最多显示 3 个名字)
|
||
val previewNames = clients.take(3).joinToString(" ") { it.nickname }
|
||
Text(
|
||
text = previewNames,
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
maxLines = 1,
|
||
overflow = TextOverflow.Ellipsis
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.6 底部语音控制区(基础)
|
||
|
||
**目标**:实现基础语音控制栏,包含静音按钮、PTT 和展开入口。
|
||
|
||
**任务**:
|
||
|
||
```kotlin
|
||
@Composable
|
||
fun VoiceControlBar(
|
||
voiceViewModel: VoiceViewModel,
|
||
onExpand: () -> Unit
|
||
) {
|
||
val isMuted by voiceViewModel.isMuted.collectAsState()
|
||
val isTransmitting by voiceViewModel.isTransmitting.collectAsState()
|
||
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.background(MaterialTheme.colorScheme.surface)
|
||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.SpaceBetween
|
||
) {
|
||
// 静音按钮
|
||
IconButton(onClick = { voiceViewModel.toggleMute() }) {
|
||
Icon(
|
||
imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||
contentDescription = if (isMuted) "取消静音" else "静音",
|
||
tint = if (isMuted) Color.Red else MaterialTheme.colorScheme.onSurface
|
||
)
|
||
}
|
||
|
||
// PTT 按钮
|
||
Button(
|
||
onClick = {},
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
.height(48.dp)
|
||
.pointerInput(Unit) {
|
||
detectDragGestures(
|
||
onDragStart = { voiceViewModel.startTransmit() },
|
||
onDragEnd = { voiceViewModel.stopTransmit() },
|
||
onDragCancel = { voiceViewModel.stopTransmit() },
|
||
onDrag = { _, _ -> }
|
||
)
|
||
},
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = if (isTransmitting) Color.Green
|
||
else MaterialTheme.colorScheme.primaryContainer
|
||
)
|
||
) {
|
||
Text(if (isTransmitting) "正在发言..." else "PTT 按住发言")
|
||
}
|
||
|
||
// 展开语音卡按钮
|
||
IconButton(onClick = onExpand) {
|
||
Icon(Icons.Default.ExpandLess, contentDescription = "展开语音卡")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.7 ChannelViewModel 实现
|
||
|
||
**目标**:实现频道列表页的核心状态管理,包含增量同步和补偿同步。
|
||
|
||
**任务**:
|
||
|
||
1. **ChannelViewModel 定义**
|
||
```kotlin
|
||
// viewmodel/ChannelViewModel.kt
|
||
class ChannelViewModel(
|
||
private val repository: ChannelRepository,
|
||
private val application: Application
|
||
) : ViewModel() {
|
||
|
||
companion object {
|
||
private const val TAG = "ChannelViewModel"
|
||
}
|
||
|
||
// ─── 同步状态 ─────────────────────────────────────────
|
||
private val _syncState = MutableStateFlow<SyncState>(SyncState.Unsynced)
|
||
val syncState: StateFlow<SyncState> = _syncState
|
||
|
||
// ─── 频道数据 ─────────────────────────────────────────
|
||
val channels: StateFlow<List<ChannelInfo>> = repository.channels
|
||
val clients: StateFlow<List<ClientInfo>> = repository.clients
|
||
val selfClientId: StateFlow<Int?> = repository.selfClientId
|
||
val channelClients: StateFlow<Map<Long, List<ClientInfo>>> = repository.channelClients
|
||
|
||
// ─── UI 状态 ─────────────────────────────────────────
|
||
private val _expandedChannelIds = MutableStateFlow<Set<Long>>(emptySet())
|
||
val expandedChannelIds: StateFlow<Set<Long>> = _expandedChannelIds
|
||
|
||
private val _unreadStates = MutableStateFlow<Map<Long, UnreadState>>(emptyMap())
|
||
val unreadStates: StateFlow<Map<Long, UnreadState>> = _unreadStates
|
||
|
||
// ─── 频道树 ─────────────────────────────────────────
|
||
val channelTree: StateFlow<List<ChannelTreeNode>> = combine(
|
||
channels, clients, expandedChannelIds, unreadStates
|
||
) { chs, cls, expanded, unread ->
|
||
buildChannelTree(chs, cls, expanded, unread)
|
||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||
|
||
// ─── 当前频道 ────────────────────────────────────────
|
||
val currentChannelId: StateFlow<Long> = repository.selfChannelId
|
||
}
|
||
```
|
||
|
||
2. **首次同步方法**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 执行首次同步,由 ServerViewModel 在 onConnected 后调用
|
||
*/
|
||
suspend fun performInitialSync() {
|
||
_syncState.value = SyncState.Syncing
|
||
|
||
try {
|
||
// 并行请求三个数据源(TsClient 直接返回 Kotlin 类型)
|
||
val channels = TSBridge.getChannelList()
|
||
val clients = TSBridge.getClientList()
|
||
val selfId = TSBridge.getClientId()
|
||
|
||
// 原子提交到仓库
|
||
repository.updateBaseline(
|
||
channels = channels,
|
||
clients = clients,
|
||
selfClientId = selfId.toInt()
|
||
)
|
||
|
||
// 自动展开有成员的频道
|
||
autoExpandChannelsWithClients()
|
||
|
||
_syncState.value = SyncState.Synchronized
|
||
Log.d(TAG, "Initial sync completed: ${channels.size} channels, ${clients.size} clients")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Initial sync failed", e)
|
||
_syncState.value = SyncState.SyncFailed(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 带重试的首次同步
|
||
*/
|
||
suspend fun performInitialSyncWithRetry(maxRetries: Int = 3) {
|
||
var retryCount = 0
|
||
|
||
while (retryCount < maxRetries) {
|
||
try {
|
||
performInitialSync()
|
||
if (_syncState.value == SyncState.Synchronized) return
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "Sync attempt ${retryCount + 1} failed", e)
|
||
}
|
||
retryCount++
|
||
if (retryCount < maxRetries) {
|
||
delay(1000L * retryCount) // 递增延迟
|
||
}
|
||
}
|
||
|
||
// 所有重试失败
|
||
if (_syncState.value != SyncState.Synchronized) {
|
||
_syncState.value = SyncState.SyncFailed(Exception("同步失败,已重试 $maxRetries 次"))
|
||
}
|
||
}
|
||
|
||
fun retrySync() {
|
||
viewModelScope.launch {
|
||
performInitialSyncWithRetry()
|
||
}
|
||
}
|
||
```
|
||
|
||
3. **增量同步 — OnClientEnter**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 处理客户端进入事件(增量同步)
|
||
* 对应 08 状态同步 ②
|
||
*/
|
||
fun handleClientEnter(clientInfo: ClientInfo) {
|
||
Log.d(TAG, "Client enter: ${clientInfo.id} (${clientInfo.nickname}) -> channel ${clientInfo.channelId}")
|
||
|
||
// 按 ID 覆盖,幂等操作
|
||
repository.addOrUpdateClient(clientInfo)
|
||
}
|
||
```
|
||
|
||
4. **增量同步 — OnClientMoved**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 处理客户端移动事件(增量同步 + 补偿同步)
|
||
* 对应 08 状态同步 ② + ③
|
||
*/
|
||
fun handleClientMoved(clientId: Int, targetChannelId: Long) {
|
||
Log.d(TAG, "Client moved: $clientId -> channel $targetChannelId")
|
||
|
||
val existingClient = repository.getClientById(clientId)
|
||
|
||
if (existingClient != null) {
|
||
// 成员存在:更新频道位置
|
||
repository.updateClientChannel(clientId, targetChannelId)
|
||
|
||
// 如果是自己,更新当前频道
|
||
if (clientId == repository.selfClientId.value) {
|
||
repository.updateSelfChannel(targetChannelId)
|
||
clearUnread(targetChannelId)
|
||
}
|
||
} else {
|
||
// 成员不存在:触发补偿同步(08 状态同步 ③)
|
||
Log.w(TAG, "Unknown client $clientId, triggering compensation sync")
|
||
viewModelScope.launch { compensateClientList() }
|
||
}
|
||
}
|
||
```
|
||
|
||
5. **增量同步 — OnClientLeave**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 处理客户端离开事件(增量同步)
|
||
* 对应 08 状态同步 ②
|
||
*/
|
||
fun handleClientLeave(clientId: Int, reasonMsg: String) {
|
||
Log.d(TAG, "Client leave: $clientId, reason: $reasonMsg")
|
||
|
||
// 幂等删除,重复删除安全
|
||
repository.removeClient(clientId)
|
||
}
|
||
```
|
||
|
||
6. **补偿同步**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 补偿同步:重新获取完整成员列表
|
||
* 对应 08 状态同步 ③
|
||
*/
|
||
private suspend fun compensateClientList() {
|
||
try {
|
||
val clients = TSBridge.getClientList()
|
||
repository.replaceAllClients(clients)
|
||
Log.d(TAG, "Compensation sync completed: ${clients.size} clients")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Compensation sync failed", e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 补偿同步:重新获取完整频道列表
|
||
* 当事件引用未知频道时触发
|
||
*/
|
||
private suspend fun compensateChannelList() {
|
||
try {
|
||
val channels = TSBridge.getChannelList()
|
||
repository.replaceAllChannels(channels)
|
||
Log.d(TAG, "Channel compensation sync completed: ${channels.size} channels")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Channel compensation sync failed", e)
|
||
}
|
||
}
|
||
```
|
||
|
||
7. **频道展开/折叠**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
fun toggleExpand(channelId: Long) {
|
||
_expandedChannelIds.value = _expandedChannelIds.value.toMutableSet().apply {
|
||
if (contains(channelId)) remove(channelId) else add(channelId)
|
||
}
|
||
}
|
||
|
||
private fun autoExpandChannelsWithClients() {
|
||
val clientsByChannel = repository.channelClients.value
|
||
val toExpand = clientsByChannel.filter { it.value.isNotEmpty() }.keys
|
||
_expandedChannelIds.value = toExpand
|
||
}
|
||
```
|
||
|
||
8. **事件处理器注册(供 ServerViewModel 调用)**
|
||
```kotlin
|
||
// ChannelViewModel.kt
|
||
|
||
/**
|
||
* 注册事件处理器,由 ServerViewModel 在 Connect 前调用
|
||
* 事件处理器在 Connect 前必须完成注册,避免早期事件丢失
|
||
*/
|
||
fun registerEventHandlers(callbacks: TSBridge.Callbacks) {
|
||
// 通过 ServerViewModel 的事件回调链式处理
|
||
// 具体实现在 ServerViewModel.registerEventHandlers() 中
|
||
// ChannelViewModel 的方法被 ServerViewModel 调用
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、状态与数据流
|
||
|
||
### 3.1 数据流向
|
||
|
||
```
|
||
ServerViewModel ChannelViewModel UI
|
||
│ │ │
|
||
│ OnConnected │ │
|
||
├─────────────────────────────→│ performInitialSync() │
|
||
│ │ │
|
||
│ OnClientEnter(info) │ │
|
||
├─────────────────────────────→│ handleClientEnter() │
|
||
│ │ │
|
||
│ OnClientMoved(id, channelId) │ │
|
||
├─────────────────────────────→│ handleClientMoved() │
|
||
│ │ │
|
||
│ OnClientLeave(id, reason) │ │
|
||
├─────────────────────────────→│ handleClientLeave() │
|
||
│ │ │
|
||
│ OnTextMessage(msg) │ │
|
||
├─────────────────────────────→│ onTextMessage() │
|
||
│ │ │
|
||
│ │ channelTree (StateFlow) │
|
||
│ ├─────────────────────────→│
|
||
│ │ │
|
||
│ │ syncState (StateFlow) │
|
||
│ ├─────────────────────────→│
|
||
```
|
||
|
||
### 3.2 同步状态机
|
||
|
||
```kotlin
|
||
sealed class SyncState {
|
||
object Unsynced : SyncState() // 已连接但尚无完整数据
|
||
object Syncing : SyncState() // 调用 ListChannels 和 ListClients
|
||
object Synchronized : SyncState() // 列表基线可供 UI 使用
|
||
data class SyncFailed(val error: Throwable) : SyncState() // 同步失败
|
||
}
|
||
```
|
||
|
||
状态转换:
|
||
- `Unsynced → Syncing`:收到 OnConnected 后开始首次同步
|
||
- `Syncing → Synchronized`:三个数据源全部成功
|
||
- `Syncing → SyncFailed`:任一数据源失败
|
||
- `SyncFailed → Syncing`:用户点击重试
|
||
|
||
### 3.3 成员实体状态树
|
||
|
||
对应 `docs/流程/02_浏览频道.md` 成员实体状态树:
|
||
|
||
```
|
||
成员实体状态树
|
||
├── 基线分支(首次同步)
|
||
│ ├── ListClients 获取完整成员列表
|
||
│ ├── 按 ClientInfo.ID 建表去重
|
||
│ └── 按 ChannelID 建索引
|
||
│
|
||
├── 增量分支(事件驱动)
|
||
│ ├── OnClientEnter → set(ID, info)
|
||
│ ├── OnClientMoved → 覆盖 ChannelID
|
||
│ └── OnClientLeave → delete(ID)
|
||
│
|
||
├── 当前用户分支
|
||
│ ├── ClientID 本地调用
|
||
│ ├── 事件 ClientID 比对
|
||
│ └── 更新自身频道事实
|
||
│
|
||
└── 修复分支(补偿同步)
|
||
├── 检测未知 ClientID → ListClients
|
||
└── 检测未知 ChannelID → ListChannels
|
||
```
|
||
|
||
---
|
||
|
||
## 四、验收标准
|
||
|
||
### 功能验收
|
||
|
||
- [ ] **频道树渲染**
|
||
- 频道按层级正确缩进显示
|
||
- 展开/折叠交互正常
|
||
- 折叠时显示频道内人数
|
||
- 有密码频道显示 🔒 图标
|
||
|
||
- [ ] **成员显示**
|
||
- 成员按所属频道正确分组
|
||
- 自己的昵称高亮显示
|
||
- 点击成员弹出操作菜单
|
||
|
||
- [ ] **未读指示**
|
||
- 非当前频道收到消息时显示红点
|
||
- 消息包含 @昵称 时显示数字 badge
|
||
- 进入频道后清除未读标记
|
||
|
||
- [ ] **当前频道栏**
|
||
- 正确显示当前频道名和人数
|
||
- 显示成员预览(最多 3 个名字)
|
||
- 点击跳转到聊天页
|
||
|
||
- [ ] **同步功能**
|
||
- 首次同步完成后频道树正确显示
|
||
- 同步中显示加载指示器
|
||
- 同步失败显示重试按钮
|
||
- 新成员进入时增量更新
|
||
- 成员离开时增量移除
|
||
- 成员移动时增量更新位置
|
||
- 未知成员触发补偿同步
|
||
|
||
### 性能验收
|
||
|
||
- [ ] 频道树渲染流畅(100 频道、500 用户无卡顿)
|
||
- [ ] 增量更新无闪烁(DiffUtil 或 Compose recompose 优化)
|
||
- [ ] 首次同步在 3 秒内完成
|
||
|
||
### 代码质量验收
|
||
|
||
- [ ] 状态管理清晰,单向数据流
|
||
- [ ] 事件处理幂等(重复事件安全处理)
|
||
- [ ] 补偿同步有日志记录
|
||
- [ ] 无内存泄漏(viewModelScope 正确使用)
|
||
|
||
### 测试用例
|
||
|
||
| 场景 | 操作 | 预期结果 |
|
||
|------|------|----------|
|
||
| 首次加载 | 连接成功后进入频道列表 | 显示加载态 → 频道树正确显示 |
|
||
| 展开频道 | 点击折叠的频道 | 显示子频道和成员 |
|
||
| 折叠频道 | 点击展开的频道 | 隐藏子频道和成员,显示人数 |
|
||
| 新成员进入 | 其他用户加入服务器 | 频道树增量更新,无需刷新 |
|
||
| 成员离开 | 其他用户退出 | 频道树增量更新,人数减少 |
|
||
| 成员移动 | 其他用户切换频道 | 两个频道的人数同步更新 |
|
||
| 未读消息 | 其他频道收到消息 | 频道名旁显示红点 |
|
||
| @提及 | 收到包含 @昵称 的消息 | 显示数字 badge |
|
||
| 进入频道 | 点击有未读的频道 | 未读标记清除 |
|
||
| 网络断开 | 断网后 | 显示断开状态,数据冻结 |
|
||
| 补偿同步 | 收到未知成员的移动事件 | 自动重新获取完整列表 |
|
||
|
||
---
|
||
|
||
## 五、参考文档
|
||
|
||
- `docs/UI架构设计.md` - 2.2 频道列表页
|
||
- `docs/流程/02_浏览频道.md` - 状态树、时序图
|
||
- `docs/流程/08_状态同步.md` - ② 增量同步、③ 补偿同步
|
||
- `docs/sdk文档-go.md` - ListChannels、ListClients、事件处理器
|