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

54 KiB
Raw Permalink Blame History

步骤 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 数据模型

    // 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 扩展 — 服务器详情获取

    // 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

    // 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 中集成服务器详情卡

    // 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 数据模型

    // 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 扩展 — 频道详情获取

    // 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

    // 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 中集成频道详情卡

    // 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 扩展 — 语音卡状态

    // 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 枚举

    // data/Models.kt
    
    enum class VoiceOutputDevice {
        SPEAKER,    // 扬声器
        EARPIECE    // 听筒
    }
    
  3. 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)
                )
            }
        }
    }
    
  4. 在 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层实现.md 2.3
  • TSBridge 已实现 onPoked 回调(docs/implementation/02_Bridge层实现.md 2.3

任务

  1. 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)
            }
        }
    }
    
  2. PokeEvent 数据模型

    // data/Models.kt
    
    @Serializable
    data class PokeEvent(
        val invokerID: Int = 0,
        val invokerName: String = "",
        val invokerUID: String = "",
        val message: String = ""
    )
    
  3. 全局 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)
        }
    }
    
  4. 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 = "关闭"
                            )
                        }
                    }
                }
            }
        }
    }
    
  5. 在 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() }
            )
        }
    }
    
  6. 事件处理器注册

    // 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 扩展

    // 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 输入弹窗

    // 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 中集成成员操作菜单

    // 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