54 KiB
步骤 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层实现.md2.3)
任务:
-
ServerInfo 数据模型
// 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 ) -
ServerViewModel 扩展 — 服务器详情获取
// 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 } -
ServerDetailCard Composable
// 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}分钟" } } -
在 ChannelListScreen 中集成服务器详情卡
// 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()方法
任务:
-
ChannelDetailInfo 数据模型
// 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 ) -
ChannelViewModel 扩展 — 频道详情获取
// 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 } -
ChannelDetailCard Composable
// 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 ) } } } -
在 ChannelListScreen 中集成频道详情卡
// 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 已实现基础语音控制
任务:
-
VoiceViewModel 扩展 — 语音卡状态
// 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: 通知底层调整输出音量 } -
VoiceOutputDevice 枚举
// data/Models.kt enum class VoiceOutputDevice { SPEAKER, // 扬声器 EARPIECE // 听筒 } -
VoiceCard Composable
// 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) ) } } } -
在 ChannelListScreen 中集成语音卡
// 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层实现.md2.3) - TSBridge 已实现
onPoked回调(docs/implementation/02_Bridge层实现.md2.3)
任务:
-
ServerViewModel 扩展 — Poke 发送
// 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) } } } -
PokeEvent 数据模型
// data/Models.kt @Serializable data class PokeEvent( val invokerID: Int = 0, val invokerName: String = "", val invokerUID: String = "", val message: String = "" ) -
全局 Poke 通知状态管理
// 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) } } -
PokeNotification 气泡通知 Composable
// 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 = "关闭" ) } } } } } } -
在 MainActivity 中集成全局 Poke 通知
// 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() } ) } } -
事件处理器注册
// 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
任务:
-
ClientActionMenu 扩展
// 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 } -
Poke 输入弹窗
// 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("取消") } } ) } -
在 ChannelListScreen 中集成成员操作菜单
// 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、OnPokeddocs/implementation/02_Bridge层实现.md- GetServerInfoJSON、GetChannelInfoJSON、Poke、onPokeddocs/implementation/05_频道列表页.md- ChannelListScreen、ClientActionMenudocs/implementation/06_频道切换.md- onChannelClickeddocs/implementation/08_语音通信.md- VoiceViewModel