refactor: 功能按钮全面布局优化 — 均布四周+平行四边形样式+互斥合并

- 起立/趴下合并为切换按钮,移至左上角
- 模式选择器(3个互斥无缝隙)移至左上角最上方
- 步态切换(4个互斥无缝隙)移至模式选择器下方,仅普通模式可点击
- 照明/休眠/充电移至右上角,紧凑排列
- 所有功能按钮使用平行四边形形状(左侧向右倾斜,右侧向左倾斜)
- 底部控制区移除更多展开区域,仅保留摇杆
- 所有控制按钮未连接时灰色不可点击

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-18 01:58:11 +08:00
parent df35ff3b05
commit b2adf46957
2 changed files with 307 additions and 194 deletions
+8 -1
View File
@@ -200,8 +200,15 @@ 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 布局:所有控制按钮在未连接时灰色不可点击
### 待办 ### 待办
- [ ] UI 布局:功能按钮均布在四周,充分利用屏幕空间,优化单手操作体验
- [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等) - [ ] 视频进一步延迟优化:探索更激进的低延迟策略(如降低帧率、缩小分辨率、调整 ExoPlayer 缓冲策略等)
@@ -17,10 +17,8 @@ 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 +38,19 @@ 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.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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 +71,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 视频背景 + 双摇杆覆盖层 + 状态栏 + 控制按钮。
* *
@@ -144,12 +181,89 @@ 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)
) {
// 照明:前灯+后灯,无缝隙
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 +450,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 +470,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 +483,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 +493,205 @@ 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(
Text( motionState: Int,
text, connected: Boolean,
color = Color(0xFFB0B0B0), onToggle: () -> Unit,
fontSize = 11.sp, modifier: Modifier = Modifier
modifier = Modifier.fillMaxWidth() ) {
) 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 = if (isStanding) "趴下" else "起立",
color = if (connected) OverlayText else Color(0x88FFFFFF),
fontSize = 12.sp,
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
Spacer(Modifier.height(2.dp)) 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
)
}
}
// ==================== 步态切换(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
)
}
} }
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================