feat: 右上角添加软急停滑块(平行四边形风格)
新增 EmergencyStopSlider 组件,水平滑到右侧触发软急停指令, 滑到左侧仅 UI 复位,等待机器人状态上报自动归位。非拖动时 跟随 motionState 状态同步滑块位置。 ControlCommands 新增 softEmergencyStop() 便捷函数。 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user