1510 lines
54 KiB
Markdown
1510 lines
54 KiB
Markdown
# 步骤 11:卡片与全局交互
|
||
|
||
> 实现三种弹出卡片和全局交互:服务器详情卡、频道详情卡、语音卡、Poke。
|
||
> 对应设计:`docs/UI架构设计.md` - 三、卡片详细设计 + 四、全局交互
|
||
> 依赖步骤:05(频道列表页)、06(频道切换)、08(语音通信)
|
||
|
||
---
|
||
|
||
## 一、目标
|
||
|
||
- [ ] 服务器详情卡(ServerDetailCard)— 服务器信息展示 + 断开连接 + 返回配置
|
||
- [ ] 频道详情卡(ChannelDetailCard)— 频道信息 + 密码切换 + 语音权限
|
||
- [ ] 语音卡(VoiceCard)— 当前发言人 + 传输控制 + 音量调节 + 耳机/扬声器
|
||
- [ ] Poke 发送与接收 — 成员菜单 Poke + 气泡通知 + 震动
|
||
- [ ] 成员操作菜单 — 扩展完整操作(Poke、踢出、封禁、移动等)
|
||
|
||
---
|
||
|
||
## 二、任务清单
|
||
|
||
### 11.1 服务器详情卡
|
||
|
||
**目标**:实现从频道列表页头部点击触发的服务器详情卡片,展示服务器信息并提供操作入口。
|
||
|
||
**前置条件**:
|
||
- 步骤 05 的 ChannelListHeader 已预留 `onOpenServerDetail` 回调
|
||
- TSBridge 已实现 `GetServerInfoJSON()` 方法(`docs/implementation/02_Bridge层实现.md` 2.3)
|
||
|
||
**任务**:
|
||
|
||
1. **ServerInfo 数据模型**
|
||
```kotlin
|
||
// data/Models.kt
|
||
|
||
@Serializable
|
||
data class ServerInfo(
|
||
val name: String = "",
|
||
val welcomeMessage: String = "",
|
||
val maxClients: Int = 0,
|
||
val clientsOnline: Int = 0,
|
||
val channelsOnline: Int = 0,
|
||
val uptime: Long = 0,
|
||
val version: String = "",
|
||
val platform: String = "",
|
||
val created: Long = 0,
|
||
val iconID: Long = 0
|
||
)
|
||
```
|
||
|
||
2. **ServerViewModel 扩展 — 服务器详情获取**
|
||
```kotlin
|
||
// viewmodel/ServerViewModel.kt
|
||
|
||
private val _serverInfo = MutableStateFlow<ServerInfo?>(null)
|
||
val serverInfo: StateFlow<ServerInfo?> = _serverInfo
|
||
|
||
private val _showServerDetailCard = MutableStateFlow(false)
|
||
val showServerDetailCard: StateFlow<Boolean> = _showServerDetailCard
|
||
|
||
/**
|
||
* 获取服务器详情信息
|
||
* 通过 TSBridge.GetServerInfoJSON() 从 Go 层获取
|
||
*/
|
||
fun fetchServerInfo() {
|
||
viewModelScope.launch {
|
||
try {
|
||
val json = TSBridge.getServerInfoJSON()
|
||
if (json.isNotEmpty() && json != "{}") {
|
||
_serverInfo.value = Json.decodeFromString<ServerInfo>(json)
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Failed to fetch server info", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
fun openServerDetailCard() {
|
||
fetchServerInfo()
|
||
_showServerDetailCard.value = true
|
||
}
|
||
|
||
fun closeServerDetailCard() {
|
||
_showServerDetailCard.value = false
|
||
}
|
||
```
|
||
|
||
3. **ServerDetailCard Composable**
|
||
```kotlin
|
||
// ui/components/cards/ServerDetailCard.kt
|
||
|
||
@Composable
|
||
fun ServerDetailCard(
|
||
serverInfo: ServerInfo?,
|
||
serverName: String,
|
||
serverAddress: String,
|
||
connectionState: ConnectionState,
|
||
onDisconnect: () -> Unit,
|
||
onBackToConfig: () -> Unit,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(16.dp),
|
||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||
) {
|
||
Column(modifier = Modifier.padding(16.dp)) {
|
||
// 标题
|
||
Text(
|
||
text = serverName,
|
||
style = MaterialTheme.typography.headlineSmall,
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
Text(
|
||
text = serverAddress,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 服务器信息区域
|
||
ServerInfoSection(serverInfo, connectionState)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 操作按钮
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
// 断开连接
|
||
OutlinedButton(
|
||
onClick = onDisconnect,
|
||
modifier = Modifier.weight(1f),
|
||
colors = ButtonDefaults.outlinedButtonColors(
|
||
contentColor = MaterialTheme.colorScheme.error
|
||
)
|
||
) {
|
||
Icon(Icons.Default.LinkOff, contentDescription = null)
|
||
Spacer(modifier = Modifier.width(4.dp))
|
||
Text("断开连接")
|
||
}
|
||
|
||
// 返回配置
|
||
Button(
|
||
onClick = onBackToConfig,
|
||
modifier = Modifier.weight(1f)
|
||
) {
|
||
Icon(Icons.Default.Settings, contentDescription = null)
|
||
Spacer(modifier = Modifier.width(4.dp))
|
||
Text("返回配置")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ServerInfoSection(
|
||
serverInfo: ServerInfo?,
|
||
connectionState: ConnectionState
|
||
) {
|
||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||
// 连接状态
|
||
InfoRow(
|
||
label = "状态",
|
||
value = when (connectionState) {
|
||
ConnectionState.Ready -> "已连接"
|
||
ConnectionState.Connecting -> "连接中..."
|
||
ConnectionState.Connected -> "已连接"
|
||
ConnectionState.Syncing -> "同步中..."
|
||
is ConnectionState.Failed -> "连接失败"
|
||
else -> "未连接"
|
||
}
|
||
)
|
||
|
||
if (serverInfo != null) {
|
||
InfoRow(label = "在线人数", value = "${serverInfo.clientsOnline}/${serverInfo.maxClients}")
|
||
InfoRow(label = "频道数", value = "${serverInfo.channelsOnline}")
|
||
InfoRow(label = "版本", value = serverInfo.version)
|
||
InfoRow(label = "平台", value = serverInfo.platform)
|
||
|
||
if (serverInfo.uptime > 0) {
|
||
InfoRow(label = "运行时长", value = formatUptime(serverInfo.uptime))
|
||
}
|
||
|
||
if (serverInfo.welcomeMessage.isNotEmpty()) {
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
Text(
|
||
text = "欢迎消息",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
Text(
|
||
text = serverInfo.welcomeMessage,
|
||
style = MaterialTheme.typography.bodySmall
|
||
)
|
||
}
|
||
} else {
|
||
Text(
|
||
text = "正在加载服务器信息...",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun InfoRow(label: String, value: String) {
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.SpaceBetween
|
||
) {
|
||
Text(
|
||
text = label,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
Text(
|
||
text = value,
|
||
style = MaterialTheme.typography.bodyMedium
|
||
)
|
||
}
|
||
}
|
||
|
||
private fun formatUptime(seconds: Long): String {
|
||
val days = seconds / 86400
|
||
val hours = (seconds % 86400) / 3600
|
||
val minutes = (seconds % 3600) / 60
|
||
return when {
|
||
days > 0 -> "${days}天${hours}小时"
|
||
hours > 0 -> "${hours}小时${minutes}分钟"
|
||
else -> "${minutes}分钟"
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **在 ChannelListScreen 中集成服务器详情卡**
|
||
```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 showServerDetailCard by serverViewModel.showServerDetailCard.collectAsState()
|
||
|
||
// ... 已有布局 ...
|
||
|
||
// 服务器详情卡
|
||
if (showServerDetailCard) {
|
||
ServerDetailCard(
|
||
serverInfo = serverViewModel.serverInfo.collectAsState().value,
|
||
serverName = serverViewModel.serverName,
|
||
serverAddress = serverViewModel.serverAddress,
|
||
connectionState = serverViewModel.connectionState.collectAsState().value,
|
||
onDisconnect = {
|
||
serverViewModel.closeServerDetailCard()
|
||
serverViewModel.disconnect()
|
||
},
|
||
onBackToConfig = {
|
||
serverViewModel.closeServerDetailCard()
|
||
onNavigateToServerConfig()
|
||
},
|
||
onDismiss = { serverViewModel.closeServerDetailCard() }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 11.2 频道详情卡
|
||
|
||
**目标**:实现频道详情卡片,展示频道信息并提供频道切换和语音权限控制。
|
||
|
||
**前置条件**:
|
||
- 步骤 05 的 ChannelListHeader 已预留 `onOpenChannelDetail` 回调
|
||
- TSBridge 已实现 `GetChannelInfoJSON()` 方法
|
||
|
||
**任务**:
|
||
|
||
1. **ChannelDetailInfo 数据模型**
|
||
```kotlin
|
||
// data/Models.kt
|
||
|
||
@Serializable
|
||
data class ChannelDetailInfo(
|
||
val id: Long = 0,
|
||
val name: String = "",
|
||
val topic: String = "",
|
||
val description: String = "",
|
||
val password: String = "", // 是否有密码("1"=有)
|
||
val maxClients: Int = -1, // -1 = 无限制
|
||
val clients: Int = 0,
|
||
val codec: Int = 0,
|
||
val codecQuality: Int = 0,
|
||
val order: Int = 0,
|
||
val parentId: Long = 0
|
||
)
|
||
```
|
||
|
||
2. **ChannelViewModel 扩展 — 频道详情获取**
|
||
```kotlin
|
||
// viewmodel/ChannelViewModel.kt
|
||
|
||
private val _channelDetailInfo = MutableStateFlow<ChannelDetailInfo?>(null)
|
||
val channelDetailInfo: StateFlow<ChannelDetailInfo?> = _channelDetailInfo
|
||
|
||
private val _showChannelDetailCard = MutableStateFlow(false)
|
||
val showChannelDetailCard: StateFlow<Boolean> = _showChannelDetailCard
|
||
|
||
/**
|
||
* 获取频道详情信息
|
||
*/
|
||
fun fetchChannelDetail(channelId: Long) {
|
||
viewModelScope.launch {
|
||
try {
|
||
val json = TSBridge.getChannelInfoJSON(channelId.toString())
|
||
if (json.isNotEmpty() && json != "{}") {
|
||
_channelDetailInfo.value = Json.decodeFromString<ChannelDetailInfo>(json)
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Failed to fetch channel info", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
fun openChannelDetailCard(channelId: Long) {
|
||
fetchChannelDetail(channelId)
|
||
_showChannelDetailCard.value = true
|
||
}
|
||
|
||
fun closeChannelDetailCard() {
|
||
_showChannelDetailCard.value = false
|
||
_channelDetailInfo.value = null
|
||
}
|
||
```
|
||
|
||
3. **ChannelDetailCard Composable**
|
||
```kotlin
|
||
// ui/components/cards/ChannelDetailCard.kt
|
||
|
||
@Composable
|
||
fun ChannelDetailCard(
|
||
channelDetail: ChannelDetailInfo?,
|
||
isCurrentChannel: Boolean,
|
||
onSwitchChannel: () -> Unit,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(16.dp),
|
||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||
) {
|
||
Column(modifier = Modifier.padding(16.dp)) {
|
||
// 标题
|
||
Text(
|
||
text = channelDetail?.name ?: "频道详情",
|
||
style = MaterialTheme.typography.headlineSmall,
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 频道信息
|
||
if (channelDetail != null) {
|
||
ChannelInfoSection(channelDetail)
|
||
} else {
|
||
Text(
|
||
text = "正在加载频道信息...",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
}
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 操作按钮
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.End
|
||
) {
|
||
TextButton(onClick = onDismiss) {
|
||
Text("关闭")
|
||
}
|
||
|
||
if (!isCurrentChannel) {
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
Button(onClick = onSwitchChannel) {
|
||
Text("切换到此频道")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ChannelInfoSection(channelDetail: ChannelDetailInfo) {
|
||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||
if (channelDetail.topic.isNotEmpty()) {
|
||
InfoRow(label = "主题", value = channelDetail.topic)
|
||
}
|
||
|
||
InfoRow(
|
||
label = "人数",
|
||
value = if (channelDetail.maxClients >= 0) {
|
||
"${channelDetail.clients}/${channelDetail.maxClients}"
|
||
} else {
|
||
"${channelDetail.clients}"
|
||
}
|
||
)
|
||
|
||
if (channelDetail.description.isNotEmpty()) {
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
Text(
|
||
text = "描述",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
Text(
|
||
text = channelDetail.description,
|
||
style = MaterialTheme.typography.bodySmall
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **在 ChannelListScreen 中集成频道详情卡**
|
||
```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 showChannelDetailCard by channelViewModel.showChannelDetailCard.collectAsState()
|
||
val channelDetail by channelViewModel.channelDetailInfo.collectAsState()
|
||
val currentChannelId by channelViewModel.currentChannelId.collectAsState()
|
||
|
||
// ... 已有布局 ...
|
||
|
||
// 频道详情卡
|
||
if (showChannelDetailCard) {
|
||
ChannelDetailCard(
|
||
channelDetail = channelDetail,
|
||
isCurrentChannel = channelDetail?.id == currentChannelId,
|
||
onSwitchChannel = {
|
||
channelDetail?.let { channel ->
|
||
channelViewModel.closeChannelDetailCard()
|
||
channelViewModel.onChannelClicked(
|
||
ChannelInfo(
|
||
id = channel.id,
|
||
name = channel.name,
|
||
isPassword = channel.password == "1"
|
||
)
|
||
)
|
||
}
|
||
},
|
||
onDismiss = { channelViewModel.closeChannelDetailCard() }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 11.3 语音卡
|
||
|
||
**目标**:实现语音控制卡片,提供完整的语音交互控制。
|
||
|
||
**前置条件**:
|
||
- 步骤 05 的 VoiceControlBar 已预留 `onExpand` 回调
|
||
- 步骤 08 的 VoiceViewModel 已实现基础语音控制
|
||
|
||
**任务**:
|
||
|
||
1. **VoiceViewModel 扩展 — 语音卡状态**
|
||
```kotlin
|
||
// viewmodel/VoiceViewModel.kt
|
||
|
||
// ─── 语音卡状态 ─────────────────────────────────────────
|
||
private val _showVoiceCard = MutableStateFlow(false)
|
||
val showVoiceCard: StateFlow<Boolean> = _showVoiceCard
|
||
|
||
private val _outputDevice = MutableStateFlow(VoiceOutputDevice.SPEAKER)
|
||
val outputDevice: StateFlow<VoiceOutputDevice> = _outputDevice
|
||
|
||
private val _inputVolume = MutableStateFlow(1.0f)
|
||
val inputVolume: StateFlow<Float> = _inputVolume
|
||
|
||
private val _outputVolume = MutableStateFlow(1.0f)
|
||
val outputVolume: StateFlow<Float> = _outputVolume
|
||
|
||
fun openVoiceCard() {
|
||
_showVoiceCard.value = true
|
||
}
|
||
|
||
fun closeVoiceCard() {
|
||
_showVoiceCard.value = false
|
||
}
|
||
|
||
fun switchOutputDevice(device: VoiceOutputDevice) {
|
||
_outputDevice.value = device
|
||
// TODO: 通知底层切换音频输出设备
|
||
}
|
||
|
||
fun setInputVolume(volume: Float) {
|
||
_inputVolume.value = volume.coerceIn(0f, 1f)
|
||
// TODO: 通知底层调整输入音量
|
||
}
|
||
|
||
fun setOutputVolume(volume: Float) {
|
||
_outputVolume.value = volume.coerceIn(0f, 1f)
|
||
// TODO: 通知底层调整输出音量
|
||
}
|
||
```
|
||
|
||
2. **VoiceOutputDevice 枚举**
|
||
```kotlin
|
||
// data/Models.kt
|
||
|
||
enum class VoiceOutputDevice {
|
||
SPEAKER, // 扬声器
|
||
EARPIECE // 听筒
|
||
}
|
||
```
|
||
|
||
3. **VoiceCard Composable**
|
||
```kotlin
|
||
// ui/components/cards/VoiceCard.kt
|
||
|
||
@Composable
|
||
fun VoiceCard(
|
||
voiceViewModel: VoiceViewModel,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
val isMuted by voiceViewModel.isMuted.collectAsState()
|
||
val isTransmitting by voiceViewModel.isTransmitting.collectAsState()
|
||
val isSpeaking by voiceViewModel.isSpeaking.collectAsState()
|
||
val speakingClientId by voiceViewModel.speakingClientId.collectAsState()
|
||
val outputDevice by voiceViewModel.outputDevice.collectAsState()
|
||
val inputVolume by voiceViewModel.inputVolume.collectAsState()
|
||
val outputVolume by voiceViewModel.outputVolume.collectAsState()
|
||
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(16.dp),
|
||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||
) {
|
||
Column(modifier = Modifier.padding(16.dp)) {
|
||
// 标题
|
||
Text(
|
||
text = "语音控制",
|
||
style = MaterialTheme.typography.headlineSmall,
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 当前发言人指示
|
||
if (isSpeaking) {
|
||
SpeakingIndicator(speakingClientId)
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
}
|
||
|
||
// 传输控制
|
||
TransmissionControlSection(
|
||
isMuted = isMuted,
|
||
isTransmitting = isTransmitting,
|
||
onToggleMute = { voiceViewModel.toggleMute() },
|
||
onStartTransmit = { voiceViewModel.startTransmit() },
|
||
onStopTransmit = { voiceViewModel.stopTransmit() }
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
HorizontalDivider()
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 输出设备切换
|
||
OutputDeviceSection(
|
||
currentDevice = outputDevice,
|
||
onSwitch = { voiceViewModel.switchOutputDevice(it) }
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
// 音量控制
|
||
VolumeControlSection(
|
||
inputVolume = inputVolume,
|
||
outputVolume = outputVolume,
|
||
onInputChange = { voiceViewModel.setInputVolume(it) },
|
||
onOutputChange = { voiceViewModel.setOutputVolume(it) }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun SpeakingIndicator(clientId: Int?) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.background(
|
||
MaterialTheme.colorScheme.primaryContainer,
|
||
RoundedCornerShape(8.dp)
|
||
)
|
||
.padding(12.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Icon(
|
||
imageVector = Icons.Default.VolumeUp,
|
||
contentDescription = null,
|
||
tint = MaterialTheme.colorScheme.primary
|
||
)
|
||
Spacer(modifier = Modifier.width(8.dp))
|
||
Text(
|
||
text = if (clientId != null) "用户 #$clientId 正在发言" else "有人正在发言",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||
)
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun TransmissionControlSection(
|
||
isMuted: Boolean,
|
||
isTransmitting: Boolean,
|
||
onToggleMute: () -> Unit,
|
||
onStartTransmit: () -> Unit,
|
||
onStopTransmit: () -> Unit
|
||
) {
|
||
Column {
|
||
Text(
|
||
text = "传输控制",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
// 静音按钮
|
||
IconButton(
|
||
onClick = onToggleMute,
|
||
modifier = Modifier
|
||
.weight(1f)
|
||
.height(56.dp)
|
||
.background(
|
||
if (isMuted) MaterialTheme.colorScheme.errorContainer
|
||
else MaterialTheme.colorScheme.surfaceVariant,
|
||
RoundedCornerShape(8.dp)
|
||
)
|
||
) {
|
||
Icon(
|
||
imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||
contentDescription = if (isMuted) "取消静音" else "静音",
|
||
tint = if (isMuted) MaterialTheme.colorScheme.error
|
||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
}
|
||
|
||
// PTT 按钮
|
||
Button(
|
||
onClick = {},
|
||
modifier = Modifier
|
||
.weight(3f)
|
||
.height(56.dp)
|
||
.pointerInput(Unit) {
|
||
detectDragGestures(
|
||
onDragStart = { onStartTransmit() },
|
||
onDragEnd = { onStopTransmit() },
|
||
onDragCancel = { onStopTransmit() },
|
||
onDrag = { _, _ -> }
|
||
)
|
||
},
|
||
colors = ButtonDefaults.buttonColors(
|
||
containerColor = if (isTransmitting) Color.Green
|
||
else MaterialTheme.colorScheme.primary
|
||
)
|
||
) {
|
||
Text(
|
||
text = if (isTransmitting) "正在发言..." else "PTT 按住发言",
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun OutputDeviceSection(
|
||
currentDevice: VoiceOutputDevice,
|
||
onSwitch: (VoiceOutputDevice) -> Unit
|
||
) {
|
||
Column {
|
||
Text(
|
||
text = "输出设备",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth(),
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
FilterChip(
|
||
selected = currentDevice == VoiceOutputDevice.EARPIECE,
|
||
onClick = { onSwitch(VoiceOutputDevice.EARPIECE) },
|
||
label = { Text("听筒") },
|
||
leadingIcon = {
|
||
Icon(Icons.Default.Phone, contentDescription = null)
|
||
},
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
|
||
FilterChip(
|
||
selected = currentDevice == VoiceOutputDevice.SPEAKER,
|
||
onClick = { onSwitch(VoiceOutputDevice.SPEAKER) },
|
||
label = { Text("扬声器") },
|
||
leadingIcon = {
|
||
Icon(Icons.Default.Speaker, contentDescription = null)
|
||
},
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun VolumeControlSection(
|
||
inputVolume: Float,
|
||
outputVolume: Float,
|
||
onInputChange: (Float) -> Unit,
|
||
onOutputChange: (Float) -> Unit
|
||
) {
|
||
Column {
|
||
Text(
|
||
text = "音量控制",
|
||
style = MaterialTheme.typography.labelMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(8.dp))
|
||
|
||
// 输入音量
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Icon(
|
||
imageVector = Icons.Default.Mic,
|
||
contentDescription = null,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
Slider(
|
||
value = inputVolume,
|
||
onValueChange = onInputChange,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
Text(
|
||
text = "${(inputVolume * 100).toInt()}%",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
modifier = Modifier.width(40.dp)
|
||
)
|
||
}
|
||
|
||
// 输出音量
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Icon(
|
||
imageVector = Icons.Default.VolumeUp,
|
||
contentDescription = null,
|
||
modifier = Modifier.size(20.dp)
|
||
)
|
||
Slider(
|
||
value = outputVolume,
|
||
onValueChange = onOutputChange,
|
||
modifier = Modifier.weight(1f)
|
||
)
|
||
Text(
|
||
text = "${(outputVolume * 100).toInt()}%",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
modifier = Modifier.width(40.dp)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **在 ChannelListScreen 中集成语音卡**
|
||
```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 showVoiceCard by voiceViewModel.showVoiceCard.collectAsState()
|
||
|
||
// ... 已有布局 ...
|
||
|
||
// 语音卡
|
||
if (showVoiceCard) {
|
||
VoiceCard(
|
||
voiceViewModel = voiceViewModel,
|
||
onDismiss = { voiceViewModel.closeVoiceCard() }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 11.4 Poke 交互
|
||
|
||
**目标**:实现 Poke 发送和接收,包括成员菜单触发、气泡通知和震动反馈。
|
||
|
||
**前置条件**:
|
||
- TSBridge 已实现 `Poke()` 方法(`docs/implementation/02_Bridge层实现.md` 2.3)
|
||
- TSBridge 已实现 `onPoked` 回调(`docs/implementation/02_Bridge层实现.md` 2.3)
|
||
|
||
**任务**:
|
||
|
||
1. **ServerViewModel 扩展 — Poke 发送**
|
||
```kotlin
|
||
// viewmodel/ServerViewModel.kt
|
||
|
||
/**
|
||
* 向指定用户发送 Poke
|
||
*/
|
||
fun pokeClient(clientId: Int, message: String) {
|
||
viewModelScope.launch {
|
||
try {
|
||
val error = TSBridge.poke(clientId.toString(), message)
|
||
if (error.isNotEmpty()) {
|
||
Log.w(TAG, "Poke failed: $error")
|
||
// 可选:显示错误提示
|
||
} else {
|
||
Log.d(TAG, "Poke sent to client $clientId")
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Poke failed", e)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
2. **PokeEvent 数据模型**
|
||
```kotlin
|
||
// data/Models.kt
|
||
|
||
@Serializable
|
||
data class PokeEvent(
|
||
val invokerID: Int = 0,
|
||
val invokerName: String = "",
|
||
val invokerUID: String = "",
|
||
val message: String = ""
|
||
)
|
||
```
|
||
|
||
3. **全局 Poke 通知状态管理**
|
||
```kotlin
|
||
// viewmodel/ServerViewModel.kt
|
||
|
||
private val _pokeNotification = MutableStateFlow<PokeEvent?>(null)
|
||
val pokeNotification: StateFlow<PokeEvent?> = _pokeNotification
|
||
|
||
private val _showPokeNotification = MutableStateFlow(false)
|
||
val showPokeNotification: StateFlow<Boolean> = _showPokeNotification
|
||
|
||
/**
|
||
* 处理收到的 Poke 事件
|
||
*/
|
||
fun handlePoked(event: PokeEvent) {
|
||
Log.d(TAG, "Poked by ${event.invokerName}: ${event.message}")
|
||
|
||
_pokeNotification.value = event
|
||
_showPokeNotification.value = true
|
||
|
||
// 震动反馈
|
||
triggerVibration()
|
||
|
||
// 自动隐藏通知(5秒后)
|
||
viewModelScope.launch {
|
||
delay(5000)
|
||
dismissPokeNotification()
|
||
}
|
||
}
|
||
|
||
fun dismissPokeNotification() {
|
||
_showPokeNotification.value = false
|
||
_pokeNotification.value = null
|
||
}
|
||
|
||
private fun triggerVibration() {
|
||
// 通过 Application context 获取 Vibrator
|
||
val vibrator = application.getSystemService(Vibrator::class.java)
|
||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||
vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE))
|
||
} else {
|
||
@Suppress("DEPRECATION")
|
||
vibrator?.vibrate(200)
|
||
}
|
||
}
|
||
```
|
||
|
||
4. **PokeNotification 气泡通知 Composable**
|
||
```kotlin
|
||
// ui/components/PokeNotification.kt
|
||
|
||
@Composable
|
||
fun PokeNotification(
|
||
pokeEvent: PokeEvent?,
|
||
isVisible: Boolean,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
AnimatedVisibility(
|
||
visible = isVisible && pokeEvent != null,
|
||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
|
||
) {
|
||
pokeEvent?.let { event ->
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||
.clickable { onDismiss() },
|
||
colors = CardDefaults.cardColors(
|
||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||
),
|
||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||
) {
|
||
Row(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.padding(12.dp),
|
||
verticalAlignment = Alignment.CenterVertically
|
||
) {
|
||
Icon(
|
||
imageVector = Icons.Default.TouchApp,
|
||
contentDescription = null,
|
||
tint = MaterialTheme.colorScheme.primary
|
||
)
|
||
Spacer(modifier = Modifier.width(12.dp))
|
||
Column(modifier = Modifier.weight(1f)) {
|
||
Text(
|
||
text = "${event.invokerName} 戳了你一下",
|
||
style = MaterialTheme.typography.titleSmall,
|
||
fontWeight = FontWeight.Bold
|
||
)
|
||
if (event.message.isNotEmpty()) {
|
||
Text(
|
||
text = event.message,
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||
)
|
||
}
|
||
}
|
||
IconButton(onClick = onDismiss) {
|
||
Icon(
|
||
imageVector = Icons.Default.Close,
|
||
contentDescription = "关闭"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
5. **在 MainActivity 中集成全局 Poke 通知**
|
||
```kotlin
|
||
// MainActivity.kt
|
||
|
||
@Composable
|
||
fun MainApp() {
|
||
val showPokeNotification by serverViewModel.showPokeNotification.collectAsState()
|
||
val pokeNotification by serverViewModel.pokeNotification.collectAsState()
|
||
|
||
Box(modifier = Modifier.fillMaxSize()) {
|
||
// 主内容
|
||
NavGraph(/* ... */)
|
||
|
||
// 全局 Poke 通知(显示在顶部)
|
||
PokeNotification(
|
||
pokeEvent = pokeNotification,
|
||
isVisible = showPokeNotification,
|
||
onDismiss = { serverViewModel.dismissPokeNotification() }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
6. **事件处理器注册**
|
||
```kotlin
|
||
// viewmodel/ServerViewModel.kt - 在 registerEventHandlers 中添加
|
||
|
||
fun registerEventHandlers() {
|
||
TSBridge.setCallbacks(object : TSBridge.Callbacks {
|
||
// ... 已有回调 ...
|
||
|
||
override fun onPoked(
|
||
invokerID: Int,
|
||
invokerName: String,
|
||
invokerUID: String,
|
||
message: String
|
||
) {
|
||
val event = PokeEvent(
|
||
invokerID = invokerID,
|
||
invokerName = invokerName,
|
||
invokerUID = invokerUID,
|
||
message = message
|
||
)
|
||
handlePoked(event)
|
||
}
|
||
|
||
// ... 其他回调 ...
|
||
})
|
||
}
|
||
```
|
||
|
||
### 11.5 成员操作菜单
|
||
|
||
**目标**:扩展成员操作菜单,提供完整的成员操作功能。
|
||
|
||
**前置条件**:
|
||
- 步骤 05 已实现基础 `ClientActionMenu`
|
||
|
||
**任务**:
|
||
|
||
1. **ClientActionMenu 扩展**
|
||
```kotlin
|
||
// ui/components/ClientActionMenu.kt
|
||
|
||
@Composable
|
||
fun ClientActionMenu(
|
||
client: ClientInfo,
|
||
isSelf: Boolean,
|
||
onPoke: () -> Unit,
|
||
onCopyNickname: () -> Unit,
|
||
onCopyUID: () -> Unit,
|
||
onKickFromServer: () -> Unit,
|
||
onKickFromChannel: () -> Unit,
|
||
onBan: () -> Unit,
|
||
onMoveToChannel: () -> Unit,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
AlertDialog(
|
||
onDismissRequest = onDismiss,
|
||
title = {
|
||
Column {
|
||
Text(
|
||
text = client.nickname,
|
||
style = MaterialTheme.typography.titleMedium
|
||
)
|
||
Text(
|
||
text = "UID: ${client.uid}",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
}
|
||
},
|
||
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()
|
||
}
|
||
)
|
||
|
||
ListItem(
|
||
headlineContent = { Text("复制 UID") },
|
||
leadingContent = {
|
||
Icon(Icons.Default.Fingerprint, null)
|
||
},
|
||
modifier = Modifier.clickable {
|
||
onCopyUID()
|
||
onDismiss()
|
||
}
|
||
)
|
||
|
||
// 管理员操作(需要权限检查)
|
||
if (!isSelf && hasAdminPermission()) {
|
||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||
|
||
ListItem(
|
||
headlineContent = { Text("踢出频道") },
|
||
leadingContent = {
|
||
Icon(
|
||
Icons.Default.RemoveCircle,
|
||
null,
|
||
tint = MaterialTheme.colorScheme.error
|
||
)
|
||
},
|
||
modifier = Modifier.clickable {
|
||
onKickFromChannel()
|
||
onDismiss()
|
||
}
|
||
)
|
||
|
||
ListItem(
|
||
headlineContent = { Text("踢出服务器") },
|
||
leadingContent = {
|
||
Icon(
|
||
Icons.Default.Block,
|
||
null,
|
||
tint = MaterialTheme.colorScheme.error
|
||
)
|
||
},
|
||
modifier = Modifier.clickable {
|
||
onKickFromServer()
|
||
onDismiss()
|
||
}
|
||
)
|
||
|
||
ListItem(
|
||
headlineContent = { Text("封禁") },
|
||
leadingContent = {
|
||
Icon(
|
||
Icons.Default.Gavel,
|
||
null,
|
||
tint = MaterialTheme.colorScheme.error
|
||
)
|
||
},
|
||
modifier = Modifier.clickable {
|
||
onBan()
|
||
onDismiss()
|
||
}
|
||
)
|
||
|
||
ListItem(
|
||
headlineContent = { Text("移动到频道") },
|
||
leadingContent = {
|
||
Icon(Icons.Default.MoveUp, null)
|
||
},
|
||
modifier = Modifier.clickable {
|
||
onMoveToChannel()
|
||
onDismiss()
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private fun hasAdminPermission(): Boolean {
|
||
// TODO: 检查当前用户是否有管理员权限
|
||
return false
|
||
}
|
||
```
|
||
|
||
2. **Poke 输入弹窗**
|
||
```kotlin
|
||
// ui/components/PokeDialog.kt
|
||
|
||
@Composable
|
||
fun PokeDialog(
|
||
targetName: String,
|
||
onConfirm: (message: String) -> Unit,
|
||
onDismiss: () -> Unit
|
||
) {
|
||
var message by remember { mutableStateOf("") }
|
||
|
||
AlertDialog(
|
||
onDismissRequest = onDismiss,
|
||
title = {
|
||
Text("Poke $targetName")
|
||
},
|
||
text = {
|
||
Column {
|
||
Text(
|
||
text = "发送一个戳一戳给 $targetName",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||
)
|
||
|
||
Spacer(modifier = Modifier.height(16.dp))
|
||
|
||
OutlinedTextField(
|
||
value = message,
|
||
onValueChange = { message = it },
|
||
label = { Text("消息(可选)") },
|
||
singleLine = true,
|
||
modifier = Modifier.fillMaxWidth()
|
||
)
|
||
}
|
||
},
|
||
confirmButton = {
|
||
TextButton(onClick = { onConfirm(message) }) {
|
||
Text("发送")
|
||
}
|
||
},
|
||
dismissButton = {
|
||
TextButton(onClick = onDismiss) {
|
||
Text("取消")
|
||
}
|
||
}
|
||
)
|
||
}
|
||
```
|
||
|
||
3. **在 ChannelListScreen 中集成成员操作菜单**
|
||
```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
|
||
) {
|
||
// ... 已有状态 ...
|
||
|
||
// 成员操作菜单状态
|
||
var selectedClient by remember { mutableStateOf<ClientInfo?>(null) }
|
||
var showClientMenu by remember { mutableStateOf(false) }
|
||
var showPokeDialog by remember { mutableStateOf(false) }
|
||
|
||
// ... 已有布局 ...
|
||
|
||
// 成员操作菜单
|
||
if (showClientMenu && selectedClient != null) {
|
||
ClientActionMenu(
|
||
client = selectedClient!!,
|
||
isSelf = selectedClient!!.id == channelViewModel.selfClientId.collectAsState().value,
|
||
onPoke = { showPokeDialog = true },
|
||
onCopyNickname = {
|
||
copyToClipboard(selectedClient!!.nickname)
|
||
},
|
||
onCopyUID = {
|
||
copyToClipboard(selectedClient!!.uid)
|
||
},
|
||
onKickFromServer = {
|
||
// TODO: 实现踢出服务器
|
||
},
|
||
onKickFromChannel = {
|
||
// TODO: 实现踢出频道
|
||
},
|
||
onBan = {
|
||
// TODO: 实现封禁
|
||
},
|
||
onMoveToChannel = {
|
||
// TODO: 实现移动到频道
|
||
},
|
||
onDismiss = {
|
||
showClientMenu = false
|
||
selectedClient = null
|
||
}
|
||
)
|
||
}
|
||
|
||
// Poke 弹窗
|
||
if (showPokeDialog && selectedClient != null) {
|
||
PokeDialog(
|
||
targetName = selectedClient!!.nickname,
|
||
onConfirm = { message ->
|
||
serverViewModel.pokeClient(selectedClient!!.id, message)
|
||
showPokeDialog = false
|
||
showClientMenu = false
|
||
selectedClient = null
|
||
},
|
||
onDismiss = {
|
||
showPokeDialog = false
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
private fun copyToClipboard(text: String) {
|
||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||
val clip = ClipData.newPlainText("text", text)
|
||
clipboard.setPrimaryClip(clip)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、状态与数据流
|
||
|
||
### 3.1 卡片状态管理
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────┐
|
||
│ ChannelListScreen │
|
||
├───────────────────────────────────────────────────────────────┤
|
||
│ ServerViewModel ChannelViewModel VoiceViewModel│
|
||
│ ┌─────────────────┐ ┌──────────────┐ ┌───────────┐│
|
||
│ │ serverInfo │ │ channelDetail│ │ voiceCard ││
|
||
│ │ showServerCard │ │ showChannel │ │ showCard ││
|
||
│ │ pokeNotification│ │ DetailCard │ │ outputDev ││
|
||
│ └────────┬────────┘ └──────┬───────┘ └─────┬─────┘│
|
||
│ │ │ │ │
|
||
│ ▼ ▼ ▼ │
|
||
│ ┌─────────────────┐ ┌──────────────┐ ┌───────────┐│
|
||
│ │ServerDetailCard │ │ChannelDetail │ │ VoiceCard ││
|
||
│ │ │ │ Card │ │ ││
|
||
│ └─────────────────┘ └──────────────┘ └───────────┘│
|
||
│ │
|
||
│ ┌───────────────────────────────────────────────────────────┐│
|
||
│ │ PokeNotification (全局浮动) ││
|
||
│ └───────────────────────────────────────────────────────────┘│
|
||
└───────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 3.2 数据流向
|
||
|
||
```
|
||
用户操作 ViewModel TSBridge/Go 服务端
|
||
│ │ │ │
|
||
│ 点击服务器卡 │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ openServerDetailCard() │ │
|
||
│ │ │ │
|
||
│ │ GetServerInfoJSON() │ │
|
||
│ ├───────────────────────→│ serverinfo │
|
||
│ │ ├─────────────────→│
|
||
│ │ │ 响应 │
|
||
│ │ JSON │←─────────────────┤
|
||
│ │←───────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ _serverInfo = parsed │ │
|
||
│ 显示详情卡 │ │ │
|
||
│←───────────────────┤ │ │
|
||
│ │ │ │
|
||
│ 点击断开连接 │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ disconnect() │ │
|
||
│ ├───────────────────────→│ │
|
||
│ │ │ │
|
||
```
|
||
|
||
### 3.3 Poke 事件流
|
||
|
||
```
|
||
用户操作 ViewModel TSBridge/Go 服务端
|
||
│ │ │ │
|
||
│ 点击成员 Poke │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ pokeClient(id, msg) │ │
|
||
│ ├───────────────────────→│ clientpoke │
|
||
│ │ ├─────────────────→│
|
||
│ │ │ │
|
||
│ │ │ notifyclientpoke│
|
||
│ │ onPoked(event) │←─────────────────┤
|
||
│ │←───────────────────────┤ │
|
||
│ │ │ │
|
||
│ │ handlePoked() │ │
|
||
│ │ - 更新通知状态 │ │
|
||
│ │ - 触发震动 │ │
|
||
│ │ - 启动自动隐藏定时器 │ │
|
||
│ │ │ │
|
||
│ 显示气泡通知 │ │ │
|
||
│←───────────────────┤ │ │
|
||
│ │ │ │
|
||
│ 点击关闭/5秒后 │ │ │
|
||
├───────────────────→│ │ │
|
||
│ │ dismissPokeNotification│ │
|
||
│ 隐藏通知 │ │ │
|
||
│←───────────────────┤ │ │
|
||
```
|
||
|
||
---
|
||
|
||
## 四、验收标准
|
||
|
||
### 功能验收
|
||
|
||
- [ ] **服务器详情卡**
|
||
- 点击频道列表页头部服务器按钮弹出
|
||
- 显示服务器名称、地址、在线人数、版本、运行时长
|
||
- 显示服务器欢迎消息
|
||
- 断开连接按钮正常工作
|
||
- 返回配置按钮跳转到配置页
|
||
- 点击外部区域关闭卡片
|
||
|
||
- [ ] **频道详情卡**
|
||
- 点击频道列表页头部频道按钮弹出
|
||
- 显示频道名称、主题、描述、人数
|
||
- 当前频道不显示"切换到此频道"按钮
|
||
- 非当前频道点击切换按钮触发频道切换流程
|
||
|
||
- [ ] **语音卡**
|
||
- 点击语音控制栏展开按钮弹出
|
||
- 显示当前发言人指示(有人发言时)
|
||
- PTT 按钮按住发言、松开停止
|
||
- 静音按钮切换静音状态
|
||
- 听筒/扬声器切换正常工作
|
||
- 输入/输出音量滑块调整正常
|
||
|
||
- [ ] **Poke 发送**
|
||
- 成员菜单点击 Poke 弹出输入框
|
||
- 可选输入消息内容
|
||
- 点击发送后发送 Poke 命令
|
||
- 发送成功后关闭弹窗
|
||
|
||
- [ ] **Poke 接收**
|
||
- 收到 Poke 事件后显示气泡通知
|
||
- 通知显示发送者昵称和消息内容
|
||
- 通知触发震动反馈
|
||
- 点击通知或 5 秒后自动关闭
|
||
|
||
- [ ] **成员操作菜单**
|
||
- 点击成员弹出操作菜单
|
||
- 基础操作:Poke、复制昵称、复制 UID
|
||
- 管理员操作:踢出频道、踢出服务器、封禁、移动(需权限)
|
||
- 自己不显示 Poke 选项
|
||
|
||
### 性能验收
|
||
|
||
- [ ] 卡片弹出/关闭动画流畅(< 300ms)
|
||
- [ ] 服务器信息获取 < 1 秒
|
||
- [ ] Poke 通知显示延迟 < 500ms
|
||
- [ ] 震动反馈即时(< 100ms)
|
||
|
||
### 代码质量验收
|
||
|
||
- [ ] 状态管理清晰,单向数据流
|
||
- [ ] 卡片状态由 ViewModel 管理,不使用局部状态
|
||
- [ ] Poke 事件处理幂等
|
||
- [ ] 资源正确释放(定时器、震动)
|
||
|
||
### 测试用例
|
||
|
||
| 场景 | 操作 | 预期结果 |
|
||
|------|------|----------|
|
||
| 服务器卡弹出 | 点击服务器按钮 | 卡片显示服务器信息 |
|
||
| 服务器卡断开 | 点击断开连接 | 返回配置页 |
|
||
| 频道卡弹出 | 点击频道按钮 | 卡片显示当前频道信息 |
|
||
| 频道卡切换 | 点击切换按钮 | 触发频道切换流程 |
|
||
| 语音卡展开 | 点击展开按钮 | 卡片显示语音控制 |
|
||
| 语音卡 PTT | 按住 PTT 松开 | 发言开始/停止 |
|
||
| 语音卡切换 | 点击听筒/扬声器 | 输出设备切换 |
|
||
| Poke 发送 | 选择成员 → Poke → 发送 | Poke 命令发送成功 |
|
||
| Poke 接收 | 其他用户 Poke 你 | 气泡通知 + 震动 |
|
||
| Poke 自动关闭 | 等待 5 秒 | 通知自动消失 |
|
||
| 成员菜单 | 点击成员 | 显示完整操作菜单 |
|
||
| 复制昵称 | 成员菜单 → 复制昵称 | 内容复制到剪贴板 |
|
||
|
||
---
|
||
|
||
## 五、参考文档
|
||
|
||
- `docs/UI架构设计.md` - 三、卡片详细设计(3.1 服务器详情卡、3.2 频道详情卡、3.3 语音卡)+ 四、全局交互(4.5 Poke)
|
||
- `docs/sdk文档-go.md` - GetServerInfo、GetChannelInfo、Poke、OnPoked
|
||
- `docs/implementation/02_Bridge层实现.md` - GetServerInfoJSON、GetChannelInfoJSON、Poke、onPoked
|
||
- `docs/implementation/05_频道列表页.md` - ChannelListScreen、ClientActionMenu
|
||
- `docs/implementation/06_频道切换.md` - onChannelClicked
|
||
- `docs/implementation/08_语音通信.md` - VoiceViewModel
|