Compare commits

..

4 Commits

Author SHA1 Message Date
hexone2086 9b8cd09929 feat: 右上角添加软急停滑块(平行四边形风格)
新增 EmergencyStopSlider 组件,水平滑到右侧触发软急停指令,
滑到左侧仅 UI 复位,等待机器人状态上报自动归位。非拖动时
跟随 motionState 状态同步滑块位置。

ControlCommands 新增 softEmergencyStop() 便捷函数。

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 14:59:22 +08:00
hexone2086 d4db91235b fix: 点击连接按钮后立即启动心跳线程,不再等待数据包接收
ProtocolClient.connect() 现在立即启动心跳循环(1Hz),而不是
等到 receiveLoop 首次收到数据包后才启动。心跳在 CONNECTING 和
CONNECTED 状态下均正常运行,确保连接建立过程中机器人能持续收到
心跳包。

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 14:58:19 +08:00
hexone2086 fbc3f1529f fix: 连接状态判定修复 — 收到首个数据包后才标记已连接,CONNECTING 5秒超时回退
- ProtocolClient: 仅收到首个有效数据包后设置 CONNECTED,不再 socket 创建后立即标记
- ProtocolClient: timeoutCheckLoop 在 connect() 中立即启动,覆盖 CONNECTING(5s) 和 CONNECTED(3s)
- RobotConnection: 允许 CONNECTING → TIMEOUT 合法转换
- MainScreen: CONNECTING 时点击连接按钮仅断开回到 DISCONNECTED,不再自动重连

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 12:15:43 +08:00
hexone2086 b2adf46957 refactor: 功能按钮全面布局优化 — 均布四周+平行四边形样式+互斥合并
- 起立/趴下合并为切换按钮,移至左上角
- 模式选择器(3个互斥无缝隙)移至左上角最上方
- 步态切换(4个互斥无缝隙)移至模式选择器下方,仅普通模式可点击
- 照明/休眠/充电移至右上角,紧凑排列
- 所有功能按钮使用平行四边形形状(左侧向右倾斜,右侧向左倾斜)
- 底部控制区移除更多展开区域,仅保留摇杆
- 所有控制按钮未连接时灰色不可点击

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-07-18 01:58:11 +08:00
6 changed files with 474 additions and 210 deletions
+14 -2
View File
@@ -161,7 +161,7 @@ gradlew installDebug
- **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态 - **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态
- **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃 - **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃
- **MsgId**: 从 0 递增 u16,循环回绕 - **MsgId**: 从 0 递增 u16,循环回绕
- **断线判定**: 3 秒无任何 UDP 包 → 标记离线 - **断线判定**: 收到首个有效数据包前为 `CONNECTING`5 秒无数据 → `TIMEOUT`);`CONNECTED` 3 秒无任何 UDP 包 → `TIMEOUT`
--- ---
@@ -200,8 +200,20 @@ gradlew installDebug
- [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮 - [x] UI 布局:连接按钮合并到顶部 StatusBar 的 ConnectionIndicator,删除底部独立连接按钮
- [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接 - [x] UI 布局:ConnectionIndicator 改为按钮形态(Surface + 圆角边框),仅指示器区域可点击切换连接
- [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域 - [x] UI 布局:隐藏系统状态栏(WindowInsetsControllerCompat),压缩自定义 StatusBar 高度,扩展可视区域
- [x] UI 布局:功能按钮均布在四周——左上角模式选择+步态切换+起立/趴下,右上角灯光+休眠+充电,充分利用屏幕空间,优化单手操作体验
- [x] UI 布局:起立/趴下合并为切换按钮(琥珀色=站立时显示"趴下",暗色=非站立时显示"起立"),移至左上角 StatusBar 下方
- [x] UI 布局:模式选择器(常规/导航/辅助,3个互斥按钮,无缝隙,底色显示当前模式)移至左上角,位于起立/趴上方
- [x] UI 布局:步态切换(基础/楼梯/平地敏捷/楼梯敏捷,4个互斥按钮,无缝隙)移至模式选择器下方,始终显示,仅普通模式+RL控制时可点击
- [x] UI 布局:照明/休眠/充电移至右上角,紧凑排列(前灯后灯无缝隙一行,休眠/唤醒切换,充电/充电中切换)
- [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
- [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
- [x] UI 布局:所有控制按钮在未连接时灰色不可点击
- [x] 连接状态准确性修复:`ProtocolClient` 收到首个有效数据包后才标记 `CONNECTED`,不再 socket 创建后立即标记;`CONNECTING` 状态 5 秒无响应自动超时回退 `TIMEOUT`
- [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试
- [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换
- [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING``CONNECTED` 状态下均正常运行
- [x] 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(`MOTION_DAMPING`),跟随机器人状态上报自动归位
### 待办 ### 待办
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等) - [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
@@ -89,9 +89,11 @@ class ProtocolClient(
/** /**
* 建立连接: * 建立连接:
* 1. 创建 UDP socket * 1. 创建 UDP socket
* 2. 启动接收协程 * 2. 启动接收/发送协程
* 3. 发送订阅请求 * 3. 发送订阅请求
* 4. 启动心跳和断线检测 * 4. 启动断线检测CONNECTING 5s / CONNECTED 3s 无数据 → TIMEOUT
* 5. 启动心跳 1Hz(立即发送,覆盖 CONNECTING 和 CONNECTED 状态)
* 6. 首次收到有效数据包后标记已连接
*/ */
fun connect() { fun connect() {
if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return
@@ -131,17 +133,15 @@ class ProtocolClient(
// 发送订阅请求(触发服务端 UDP 推送) // 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests() sendSubscriptionRequests()
_state.value = State.CONNECTED // 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
// 启动心跳
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
// 启动断线检测
timeoutJob = scope.launch { timeoutJob = scope.launch {
timeoutCheckLoop() timeoutCheckLoop()
} }
// 启动心跳(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
} catch (e: Exception) { } catch (e: Exception) {
_state.value = State.ERROR _state.value = State.ERROR
closeSocket() closeSocket()
@@ -232,6 +232,11 @@ class ProtocolClient(
if (report != null) { if (report != null) {
_statusReport.tryEmit(report) _statusReport.tryEmit(report)
} }
// 首次收到有效数据包:标记已连接
if (_state.value == State.CONNECTING) {
_state.value = State.CONNECTED
}
} catch (_: SocketTimeoutException) { } catch (_: SocketTimeoutException) {
// 超时正常,继续循环 // 超时正常,继续循环
continue continue
@@ -244,9 +249,11 @@ class ProtocolClient(
} }
} }
/** 心跳循环(1 Hz */ /** 心跳循环(1 Hz—— 在 CONNECTING 和 CONNECTED 状态下运行 */
private suspend fun CoroutineScope.heartbeatLoop(sock: DatagramSocket, address: InetAddress) { private suspend fun CoroutineScope.heartbeatLoop(sock: DatagramSocket, address: InetAddress) {
while (isActive && _state.value == State.CONNECTED) { while (isActive &&
(_state.value == State.CONNECTING || _state.value == State.CONNECTED)
) {
try { try {
val json = ControlCommands.heartbeat() val json = ControlCommands.heartbeat()
val packet = encoder.encode(json) val packet = encoder.encode(json)
@@ -258,13 +265,23 @@ class ProtocolClient(
} }
} }
/** 断线检测:3 秒无数据 → 标记 TIMEOUT */ /** 断线检测:CONNECTED 状态 3 秒无数据 → TIMEOUTCONNECTING 状态 5 秒无数据 → TIMEOUT */
private suspend fun CoroutineScope.timeoutCheckLoop() { private suspend fun CoroutineScope.timeoutCheckLoop() {
while (isActive && _state.value != State.DISCONNECTED) { while (isActive && _state.value != State.DISCONNECTED) {
val elapsed = System.currentTimeMillis() - lastPacketTimeMs val elapsed = System.currentTimeMillis() - lastPacketTimeMs
if (elapsed > 3000 && _state.value == State.CONNECTED) { when (_state.value) {
State.CONNECTED -> {
if (elapsed > 3000) {
_state.value = State.TIMEOUT _state.value = State.TIMEOUT
} }
}
State.CONNECTING -> {
if (elapsed > 5000) {
_state.value = State.TIMEOUT
}
}
else -> { /* DISCONNECTED/TIMEOUT/ERROR: 无需超时处理 */ }
}
delay(500) delay(500)
} }
} }
@@ -107,6 +107,9 @@ object ControlCommands {
put("Charge", JsonPrimitive(charge)) put("Charge", JsonPrimitive(charge))
} }
// ---- 2.8b 软急停 ----
fun softEmergencyStop(time: String = now()): String = motionControl(MOTION_DAMPING, time)
// ---- 2.9 休眠模式设置 ---- // ---- 2.9 休眠模式设置 ----
fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu( fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu(
type = 1101, command = 6, time = time type = 1101, command = 6, time = time
@@ -50,6 +50,7 @@ class RobotConnection {
ProtocolClient.State.CONNECTING -> ProtocolClient.State.CONNECTING ->
to == ProtocolClient.State.CONNECTED || to == ProtocolClient.State.CONNECTED ||
to == ProtocolClient.State.ERROR || to == ProtocolClient.State.ERROR ||
to == ProtocolClient.State.TIMEOUT ||
to == ProtocolClient.State.DISCONNECTED to == ProtocolClient.State.DISCONNECTED
ProtocolClient.State.CONNECTED -> ProtocolClient.State.CONNECTED ->
to == ProtocolClient.State.TIMEOUT || to == ProtocolClient.State.TIMEOUT ||
@@ -3,6 +3,7 @@ package com.example.m20_gamepad.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,14 +14,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -40,12 +40,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -66,6 +75,38 @@ import com.example.m20_gamepad.video.RtspVideoPlayer
private val OverlayBg = Color(0x88000000) private val OverlayBg = Color(0x88000000)
private val OverlayText = Color.White private val OverlayText = Color.White
/** 平行四边形形状,用于功能按钮。skewLeft=true → 左下-右上斜边(左侧按钮);
* skewLeft=false → 左上-右下斜边(右侧按钮)。 */
private class ParallelogramShape(
private val skewDp: Dp = 10.dp,
private val skewLeft: Boolean = true
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val skewPx = with(density) { skewDp.toPx() }
val path = Path().apply {
if (skewLeft) {
// 左下 → 右上 斜边(平行四边形向右倾斜)
moveTo(skewPx, 0f)
lineTo(size.width, 0f)
lineTo(size.width - skewPx, size.height)
lineTo(0f, size.height)
} else {
// 左上 → 右下 斜边(平行四边形向左倾斜)
moveTo(0f, 0f)
lineTo(size.width - skewPx, 0f)
lineTo(size.width, size.height)
lineTo(skewPx, size.height)
}
close()
}
return Outline.Generic(path)
}
}
/** /**
* 主界面:RTSP 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。 * 主界面:RTSP 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。
* *
@@ -135,7 +176,9 @@ fun MainScreen(
onStatusClick = { statusPanelVisible = true }, onStatusClick = { statusPanelVisible = true },
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onConnectToggle = { onConnectToggle = {
if (connectionState == ProtocolClient.State.CONNECTED) { if (connectionState == ProtocolClient.State.CONNECTED ||
connectionState == ProtocolClient.State.CONNECTING
) {
viewModel.disconnect() viewModel.disconnect()
} else { } else {
viewModel.connect() viewModel.connect()
@@ -144,12 +187,95 @@ fun MainScreen(
modifier = Modifier.align(Alignment.TopCenter) modifier = Modifier.align(Alignment.TopCenter)
) )
// ---- 左上角控制区:模式选择 + 步态切换 + 起立/趴下 ----
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 56.dp, start = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
ModeSelector(
currentMode = status?.basicStatus?.controlUsageMode ?: 0,
connected = connectionState == ProtocolClient.State.CONNECTED,
onSelectMode = { mode -> viewModel.sendSwitchMode(mode) }
)
// 步态切换——始终显示,仅普通模式 + RL 控制时可点击
GaitSelector(
currentGait = status?.basicStatus?.gait ?: 0,
enabled = connectionState == ProtocolClient.State.CONNECTED &&
motionState == ControlCommands.MOTION_RL_CONTROL &&
status?.basicStatus?.controlUsageMode == ControlCommands.MODE_NORMAL,
onSelectGait = { gait -> viewModel.sendSwitchGait(gait) }
)
StandToggleButton(
motionState = motionState,
connected = connectionState == ProtocolClient.State.CONNECTED,
onToggle = {
if (motionState == ControlCommands.MOTION_IDLE ||
motionState == ControlCommands.MOTION_LIE_DOWN
) {
viewModel.sendMotionControl(ControlCommands.MOTION_STAND)
} else {
viewModel.sendMotionControl(ControlCommands.MOTION_LIE_DOWN)
}
}
)
}
// ---- 右上角控制区:软急停滑块 + 照明 + 休眠 + 充电 ----
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 56.dp, end = 12.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// 软急停滑块
EmergencyStopSlider(
isEmergencyStop = motionState == ControlCommands.MOTION_DAMPING,
connected = connectionState == ProtocolClient.State.CONNECTED,
onEmergencyStop = { viewModel.sendEmergencyStop() }
)
// 照明:前灯+后灯,无缝隙
val frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
LightToggleButton(
text = "前灯",
isOn = frontOn,
enabled = connectionState == ProtocolClient.State.CONNECTED
) {
viewModel.sendLightControl(if (frontOn) 0 else 1, ledStatus?.back ?: 0)
}
LightToggleButton(
text = "后灯",
isOn = backOn,
enabled = connectionState == ProtocolClient.State.CONNECTED
) {
viewModel.sendLightControl(ledStatus?.front ?: 0, if (backOn) 0 else 1)
}
}
// 休眠/唤醒
val isSleeping = status?.basicStatus?.sleep == 1
SleepToggleButton(
isSleeping = isSleeping,
connected = connectionState == ProtocolClient.State.CONNECTED,
onToggle = { viewModel.sendSleepSettings(!isSleeping) }
)
// 充电/结束充电
val isCharging = status?.basicStatus?.charge == 1
ChargeToggleButton(
isCharging = isCharging,
connected = connectionState == ProtocolClient.State.CONNECTED,
onToggle = { viewModel.sendChargeControl(if (isCharging) 0 else 1) }
)
}
// ---- 底部控制区 ---- // ---- 底部控制区 ----
BottomControls( BottomControls(
viewModel = viewModel, viewModel = viewModel,
connectionState = connectionState, connectionState = connectionState,
motionState = motionState, motionState = motionState,
ledStatus = ledStatus,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
@@ -336,7 +462,6 @@ private fun BottomControls(
viewModel: MainViewModel, viewModel: MainViewModel,
connectionState: ProtocolClient.State, connectionState: ProtocolClient.State,
motionState: Int, motionState: Int,
ledStatus: LedStatus?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
@@ -357,14 +482,6 @@ private fun BottomControls(
} }
) )
// 中间控制按钮组
ControlButtons(
viewModel = viewModel,
connectionState = connectionState,
motionState = motionState,
ledStatus = ledStatus
)
// 右摇杆(Yaw 旋转) // 右摇杆(Yaw 旋转)
Joystick( Joystick(
modifier = Modifier.size(180.dp), modifier = Modifier.size(180.dp),
@@ -378,170 +495,6 @@ private fun BottomControls(
} }
} }
@Composable
private fun ControlButtons(
viewModel: MainViewModel,
connectionState: ProtocolClient.State,
motionState: Int,
ledStatus: LedStatus?
) {
val connected = connectionState == ProtocolClient.State.CONNECTED
val isRlControl = motionState == ControlCommands.MOTION_RL_CONTROL
var showMore by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier
.verticalScroll(scrollState)
.padding(top = 4.dp, bottom = 4.dp)
) {
// 运动状态转换
ControlButton(
"起立",
enabled = connected && (motionState == ControlCommands.MOTION_IDLE ||
motionState == ControlCommands.MOTION_LIE_DOWN)
) {
viewModel.sendMotionControl(ControlCommands.MOTION_STAND)
}
ControlButton(
"RL控制",
enabled = connected && motionState == ControlCommands.MOTION_STAND
) {
viewModel.sendMotionControl(ControlCommands.MOTION_RL_CONTROL)
}
ControlButton(
"趴下",
enabled = connected && motionState != ControlCommands.MOTION_LIE_DOWN
) {
viewModel.sendMotionControl(ControlCommands.MOTION_LIE_DOWN)
}
// 步态切换——仅 RL 控制
if (isRlControl) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("基础", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_BASIC)
}
MiniButton("楼梯", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_STAIRS)
}
MiniButton("平地敏捷", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_FLAT_AGILE)
}
}
MiniButton("楼梯敏捷", enabled = isRlControl) {
viewModel.sendSwitchGait(ControlCommands.GAIT_STAIRS_AGILE)
}
}
// 更多/收起切换
if (connected) {
ControlButton(if (showMore) "收起" else "更多", enabled = connected) {
showMore = !showMore
}
}
// ---- 更多控制(可展开) ----
if (showMore) {
Separator()
// 使用模式
SectionLabel("使用模式")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("常规") {
viewModel.sendSwitchMode(ControlCommands.MODE_NORMAL)
}
MiniButton("导航") {
viewModel.sendSwitchMode(ControlCommands.MODE_NAVIGATION)
}
MiniButton("辅助") {
viewModel.sendSwitchMode(ControlCommands.MODE_ASSIST)
}
}
Spacer(Modifier.height(4.dp))
// 照明
SectionLabel("照明")
val frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
LightToggleButton(
text = "前灯",
isOn = frontOn,
enabled = connected
) {
viewModel.sendLightControl(if (frontOn) 0 else 1, ledStatus?.back ?: 0)
}
LightToggleButton(
text = "后灯",
isOn = backOn,
enabled = connected
) {
viewModel.sendLightControl(ledStatus?.front ?: 0, if (backOn) 0 else 1)
}
}
Spacer(Modifier.height(4.dp))
// 充电
SectionLabel("充电")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("开始充电") { viewModel.sendChargeControl(1) }
MiniButton("结束充电") { viewModel.sendChargeControl(0) }
}
Spacer(Modifier.height(4.dp))
// 休眠
SectionLabel("休眠")
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MiniButton("进入休眠") { viewModel.sendSleepSettings(true) }
MiniButton("唤醒") { viewModel.sendSleepSettings(false) }
}
}
}
}
@Composable
private fun ControlButton(
text: String,
enabled: Boolean = true,
onClick: () -> Unit
) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
modifier = Modifier.width(110.dp)
) {
Text(text, color = if (enabled) OverlayText else Color(0x88FFFFFF), fontWeight = FontWeight.Medium)
}
}
@Composable
private fun MiniButton(text: String, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Normal
)
}
}
@Composable @Composable
private fun LightToggleButton( private fun LightToggleButton(
text: String, text: String,
@@ -552,40 +505,309 @@ private fun LightToggleButton(
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333), containerColor = if (isOn) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222) disabledContainerColor = Color(0x33222222)
), ),
modifier = Modifier.width(110.dp) contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) { ) {
Text( Text(
"$text ${if (isOn) "开" else "关"}", "$text ${if (isOn) "开" else "关"}",
color = if (enabled) OverlayText else Color(0x88FFFFFF), color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
} }
@Composable @Composable
private fun SectionLabel(text: String) { private fun StandToggleButton(
motionState: Int,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
val isStanding = motionState == ControlCommands.MOTION_STAND ||
motionState == ControlCommands.MOTION_RL_CONTROL
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isStanding) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text( Text(
text, text = if (isStanding) "趴下" else "起立",
color = Color(0xFFB0B0B0), color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 11.sp, fontSize = 12.sp,
modifier = Modifier.fillMaxWidth() fontWeight = FontWeight.Medium
) )
} }
}
// ==================== 模式选择器(3个互斥无缝隙) ====================
@Composable
private fun ModeSelector(
currentMode: Int,
connected: Boolean,
onSelectMode: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
ModeButton("常规", ControlCommands.MODE_NORMAL, currentMode, connected, onSelectMode)
ModeButton("导航", ControlCommands.MODE_NAVIGATION, currentMode, connected, onSelectMode)
ModeButton("辅助", ControlCommands.MODE_ASSIST, currentMode, connected, onSelectMode)
}
}
@Composable @Composable
private fun Separator() { private fun ModeButton(
Spacer(Modifier.height(2.dp)) text: String,
Box( mode: Int,
modifier = Modifier currentMode: Int,
.width(80.dp) connected: Boolean,
.height(1.dp) onClick: (Int) -> Unit
.background(Color(0x44FFFFFF)) ) {
val isActive = currentMode == mode
Button(
onClick = { onClick(mode) },
enabled = connected,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isActive) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
) )
Spacer(Modifier.height(2.dp)) }
}
// ==================== 步态切换(4个互斥无缝隙,仅普通模式可用) ====================
@Composable
private fun GaitSelector(
currentGait: Int,
enabled: Boolean,
onSelectGait: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
GaitButton("基础", ControlCommands.GAIT_BASIC, currentGait, enabled, onSelectGait)
GaitButton("楼梯", ControlCommands.GAIT_STAIRS, currentGait, enabled, onSelectGait)
GaitButton("平地敏捷", ControlCommands.GAIT_FLAT_AGILE, currentGait, enabled, onSelectGait)
GaitButton("楼梯敏捷", ControlCommands.GAIT_STAIRS_AGILE, currentGait, enabled, onSelectGait)
}
}
@Composable
private fun GaitButton(
text: String,
gait: Int,
currentGait: Int,
enabled: Boolean,
onClick: (Int) -> Unit
) {
val isActive = currentGait == gait
Button(
onClick = { onClick(gait) },
enabled = enabled,
shape = ParallelogramShape(skewLeft = true),
colors = ButtonDefaults.buttonColors(
containerColor = if (isActive) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = Modifier.height(32.dp)
) {
Text(
text,
color = if (enabled) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal
)
}
}
// ==================== 休眠/唤醒切换 ====================
@Composable
private fun SleepToggleButton(
isSleeping: Boolean,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors(
containerColor = if (isSleeping) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text(
text = if (isSleeping) "唤醒" else "休眠",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
// ==================== 充电/结束充电切换 ====================
@Composable
private fun ChargeToggleButton(
isCharging: Boolean,
connected: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onToggle,
enabled = connected,
shape = ParallelogramShape(skewLeft = false),
colors = ButtonDefaults.buttonColors(
containerColor = if (isCharging) Color(0xCCFFC107) else Color(0x66333333),
disabledContainerColor = Color(0x33222222)
),
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier.height(32.dp)
) {
Text(
text = if (isCharging) "充电中" else "充电",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
// ==================== 软急停滑块 ====================
/**
* 水平滑块,平行四边形风格。用于触发软急停。
*
* 滑到右侧 → 发送软急停指令;滑到左侧 → 仅 UI 复位,不发指令。
* 机器人收到软急停后 3 秒自动切回空闲,状态上报会驱动滑块自动归位。
*
* @param isEmergencyStop 当前是否处于软急停状态(motionState == MOTION_DAMPING
* @param connected 是否已连接(未连接时禁用交互)
* @param onEmergencyStop 滑块滑到右侧触发
*/
@Composable
private fun EmergencyStopSlider(
isEmergencyStop: Boolean,
connected: Boolean,
onEmergencyStop: () -> Unit,
modifier: Modifier = Modifier
) {
val sliderWidth = 150.dp
val sliderHeight = 32.dp
val thumbSize = 28.dp
// 0f = 左侧(正常),1f = 右侧(急停触发)
var fraction by remember { mutableStateOf(if (isEmergencyStop) 1f else 0f) }
var isDragging by remember { mutableStateOf(false) }
// 非拖动时跟随外部状态回调
LaunchedEffect(isEmergencyStop) {
if (!isDragging) {
fraction = if (isEmergencyStop) 1f else 0f
}
}
// 拖动结束后吸附:滑到右侧触发急停,滑到左侧仅复位 UI
fun snapAndFire() {
isDragging = false
if (fraction > 0.5f) {
fraction = 1f
onEmergencyStop()
} else {
fraction = 0f
// 不发送复位指令,等机器人状态上报自动同步
}
}
// 滑轨:遵循其他按钮的 containerColor 逻辑
val trackColor = if (connected) Color(0x66333333) else Color(0x33222222)
// 拇指:75% 红色
val thumbColor = Color(0xBFFF4444)
val skew = 3.dp
Box(
modifier = modifier
.width(sliderWidth)
.height(sliderHeight)
) {
// 滑轨 — 平行四边形外框
Surface(
modifier = Modifier
.fillMaxSize(),
shape = ParallelogramShape(skewDp = skew, skewLeft = false),
color = trackColor
) {}
// 文字显示在滑轨上
Text(
text = "急停",
modifier = Modifier.align(Alignment.Center),
color = Color(0xAAFFFFFF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
// 滑块拇指
val maxOffsetDp = sliderWidth - thumbSize
val density = LocalDensity.current
val maxOffsetPx = with(density) { maxOffsetDp.toPx() }
val thumbOffset = maxOffsetDp * fraction
// fraction=0 → offset=0 (左侧)
// fraction=1 → offset=maxOffsetDp (右侧)
Surface(
modifier = Modifier
.size(thumbSize, sliderHeight)
.offset(x = thumbOffset)
.pointerInput(connected) {
if (!connected) return@pointerInput
detectHorizontalDragGestures(
onDragStart = { isDragging = true },
onDragEnd = { snapAndFire() },
onDragCancel = { snapAndFire() },
onHorizontalDrag = { change, dragAmount ->
change.consume()
fraction = (fraction + dragAmount / maxOffsetPx).coerceIn(0f, 1f)
}
)
},
shape = ParallelogramShape(skewDp = skew, skewLeft = false),
color = thumbColor
) {}
}
} }
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
@@ -292,6 +292,15 @@ class MainViewModel(
client?.sendCommandBlocking(ControlCommands.chargeControl(charge)) client?.sendCommandBlocking(ControlCommands.chargeControl(charge))
} }
/** 软急停:进入关节阻尼状态。 */
fun sendEmergencyStop() {
if (robot.canSendMotionControl) {
client?.sendCommandBlocking(ControlCommands.softEmergencyStop())
} else {
_actionFeedback.value = "未连接,无法发送软急停"
}
}
fun sendSleepSettings(sleep: Boolean, auto: Boolean = false, timeMinutes: Int = 5) { fun sendSleepSettings(sleep: Boolean, auto: Boolean = false, timeMinutes: Int = 5) {
client?.sendCommandBlocking( client?.sendCommandBlocking(
ControlCommands.sleepSettings(sleep, auto, timeMinutes) ControlCommands.sleepSettings(sleep, auto, timeMinutes)