Files
ts-mobile-go/docs/implementation/11_卡片与全局交互.md
T
2026-07-20 19:01:03 +08:00

1510 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 步骤 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