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
@@ -107,6 +107,9 @@ object ControlCommands {
put("Charge", JsonPrimitive(charge))
}
// ---- 2.8b 软急停 ----
fun softEmergencyStop(time: String = now()): String = motionControl(MOTION_DAMPING, time)
// ---- 2.9 休眠模式设置 ----
fun sleepSettings(sleep: Boolean, auto: Boolean, timeMinutes: Int, time: String = now()): String = buildAsdu(
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.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -218,7 +222,7 @@ fun MainScreen(
)
}
// ---- 右上角控制区:照明 + 休眠 + 充电 ----
// ---- 右上角控制区:软急停滑块 + 照明 + 休眠 + 充电 ----
Column(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -226,6 +230,12 @@ fun MainScreen(
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
@@ -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) {
@@ -292,6 +292,15 @@ class MainViewModel(
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) {
client?.sendCommandBlocking(
ControlCommands.sleepSettings(sleep, auto, timeMinutes)