Compare commits
4 Commits
df35ff3b05
...
9b8cd09929
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b8cd09929 | |||
| d4db91235b | |||
| fbc3f1529f | |||
| b2adf46957 |
@@ -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 秒无数据 → TIMEOUT;CONNECTING 状态 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user