feat: 右上角添加软急停滑块(平行四边形风格)

新增 EmergencyStopSlider 组件,水平滑到右侧触发软急停指令,
滑到左侧仅 UI 复位,等待机器人状态上报自动归位。非拖动时
跟随 motionState 状态同步滑块位置。

ControlCommands 新增 softEmergencyStop() 便捷函数。

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-18 14:59:22 +08:00
parent d4db91235b
commit 9b8cd09929
4 changed files with 128 additions and 1 deletions
+1
View File
@@ -212,6 +212,7 @@ gradlew installDebug
- [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试 - [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试
- [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换 - [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换
- [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING``CONNECTED` 状态下均正常运行 - [x] 心跳启动时机修复:`ProtocolClient.connect()` 立即启动心跳线程,不再等待首次收到数据包后才启动;心跳在 `CONNECTING``CONNECTED` 状态下均正常运行
- [x] 软急停滑块:右上角新增平行四边形水平滑块,滑到右侧触发软急停(`MOTION_DAMPING`),跟随机器人状态上报自动归位
### 待办 ### 待办
@@ -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
@@ -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,6 +14,7 @@ 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
@@ -43,8 +45,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape 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
@@ -218,7 +222,7 @@ fun MainScreen(
) )
} }
// ---- 右上角控制区:照明 + 休眠 + 充电 ---- // ---- 右上角控制区:软急停滑块 + 照明 + 休眠 + 充电 ----
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
@@ -226,6 +230,12 @@ fun MainScreen(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp) 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 frontOn = ledStatus?.front == 1
val backOn = ledStatus?.back == 1 val backOn = ledStatus?.back == 1
@@ -696,6 +706,110 @@ private fun ChargeToggleButton(
} }
} }
// ==================== 软急停滑块 ====================
/**
* 水平滑块,平行四边形风格。用于触发软急停。
*
* 滑到右侧 → 发送软急停指令;滑到左侧 → 仅 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
) {}
}
}
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
private fun motionStateText(state: Int): String = when (state) { private fun motionStateText(state: Int): String = when (state) {
@@ -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)