Files

1510 lines
54 KiB
Markdown
Raw Permalink Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 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