feat: basic control

This commit is contained in:
玖叁 2024-12-24 18:59:12 +08:00
parent d0fe216097
commit 4ab4487197
16 changed files with 417 additions and 96 deletions

View File

@ -18,6 +18,12 @@ android {
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
buildConfigField(
type = "String",
name = "BUILD_TIME",
value = "\"${System.currentTimeMillis()}\""
)
} }
buildTypes { buildTypes {
@ -38,6 +44,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.1" kotlinCompilerExtensionVersion = "1.5.1"

View File

@ -16,12 +16,20 @@ import androidx.activity.viewModels
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -30,6 +38,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navigation
import icu.fur93.esp32_car.data.PreferencesDataStore import icu.fur93.esp32_car.data.PreferencesDataStore
import icu.fur93.esp32_car.page.ControlGamepadModePage import icu.fur93.esp32_car.page.ControlGamepadModePage
import icu.fur93.esp32_car.page.ControlPage import icu.fur93.esp32_car.page.ControlPage
@ -37,8 +46,10 @@ import icu.fur93.esp32_car.page.ControlPathfinderModePage
import icu.fur93.esp32_car.page.ControlSingleJoystickModePage import icu.fur93.esp32_car.page.ControlSingleJoystickModePage
import icu.fur93.esp32_car.page.HomePage import icu.fur93.esp32_car.page.HomePage
import icu.fur93.esp32_car.page.SettingsPage import icu.fur93.esp32_car.page.SettingsPage
import icu.fur93.esp32_car.provider.LocalNavigation
import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl
import icu.fur93.esp32_car.ui.component.BottomNavigationBar import icu.fur93.esp32_car.ui.component.BottomNavigationBar
import icu.fur93.esp32_car.ui.component.BottomNavigationItem
import icu.fur93.esp32_car.ui.theme.Esp32carTheme import icu.fur93.esp32_car.ui.theme.Esp32carTheme
import icu.fur93.esp32_car.viewmodel.CarControlUseCase import icu.fur93.esp32_car.viewmodel.CarControlUseCase
import icu.fur93.esp32_car.viewmodel.CarViewModel import icu.fur93.esp32_car.viewmodel.CarViewModel
@ -165,32 +176,65 @@ fun App(viewModel: CarViewModel) {
onDispose {} onDispose {}
} }
Scaffold( CompositionLocalProvider(
bottomBar = { LocalNavigation provides navController,
BottomNavigationBar(navController) ) {
}, Scaffold(
modifier = Modifier bottomBar = {
.fillMaxSize(), BottomNavigationBar(
containerColor = MaterialTheme.colorScheme.surface, navController = navController,
) { innerPadding -> navItems = listOf(
Box(Modifier.padding(innerPadding)) { BottomNavigationItem(
NavHost( icon = Icons.Outlined.Home,
navController = navController, selectedIcon = Icons.Default.Home,
startDestination = Route.Home.route route = Route.Home
) { ),
composable(Route.Home.route) { HomePage(viewModel) } BottomNavigationItem(
composable(Route.Control.route) { ControlPage(viewModel) } icon = Icons.AutoMirrored.Outlined.Send,
composable(Route.Settings.route) { SettingsPage(viewModel) } selectedIcon = Icons.AutoMirrored.Filled.Send,
composable(Route.ControlPathfinderMode.route) { route = Route.Control
ControlPathfinderModePage(viewModel) ),
BottomNavigationItem(
icon = Icons.Outlined.Settings,
selectedIcon = Icons.Default.Settings,
route = Route.Settings
)
)
)
},
modifier = Modifier
.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.surface,
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
NavHost(
navController = navController,
startDestination = Route.Home.route
) {
// 主导航路由
composable(Route.Home.route) { HomePage(viewModel) }
composable(Route.Settings.route) { SettingsPage(viewModel) }
// Control 相关的导航图
navigation(
startDestination = Route.Control.route,
route = "control_graph"
) {
composable(Route.Control.route) { ControlPage(viewModel) }
// Control 的子页面
composable(Route.ControlPathfinderMode.route) {
ControlPathfinderModePage(viewModel)
}
composable(Route.ControlSingleJoystickMode.route) {
ControlSingleJoystickModePage(viewModel)
}
composable(Route.ControlGamepadMode.route) {
ControlGamepadModePage(viewModel)
}
}
} }
composable(Route.ControlSingleJoystickMode.route) {
ControlSingleJoystickModePage(viewModel)
}
composable(Route.ControlGamepadMode.route) { ControlGamepadModePage(viewModel) }
} }
} }
} }
} }

View File

@ -1,10 +1,33 @@
package icu.fur93.esp32_car package icu.fur93.esp32_car
sealed class Route(val route: String) { sealed class Route(
data object Home : Route("home") val route: String,
data object Control : Route("control") val title: String = route
data object Settings : Route("settings") ) {
data object ControlPathfinderMode : Route("control_pathfinder_mode") object Home : Route(
data object ControlSingleJoystickMode : Route("control_single_joystick_mode") route = "home",
data object ControlGamepadMode : Route("control_gamepad_mode") title = "首页"
)
object Settings : Route(
route = "settings",
title = "设置"
)
// Control 相关路由
object Control : Route(
route = "control",
title = "控制"
)
object ControlPathfinderMode : Route(
route = "control/pathfinder",
title = "循迹模式"
)
object ControlSingleJoystickMode : Route(
route = "control/single_joystick",
title = "单摇杆模式"
)
object ControlGamepadMode : Route(
route = "control/gamepad",
title = "单向按键模式"
)
} }

View File

@ -0,0 +1,8 @@
package icu.fur93.esp32_car.entity.control
data class JoystickState(
val x: Float = 0f, // -1 到 1
val y: Float = 0f, // -1 到 1
val angle: Float = 0f, // 角度(弧度)
val distance: Float = 0f // 0 到 1
)

View File

@ -13,6 +13,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import icu.fur93.esp32_car.R import icu.fur93.esp32_car.R
import icu.fur93.esp32_car.Route
import icu.fur93.esp32_car.provider.LocalNavigation
import icu.fur93.esp32_car.repository.ConnectionState import icu.fur93.esp32_car.repository.ConnectionState
import icu.fur93.esp32_car.ui.card.UnconnectedStatusCard import icu.fur93.esp32_car.ui.card.UnconnectedStatusCard
import icu.fur93.esp32_car.ui.component.CardButtonGroup import icu.fur93.esp32_car.ui.component.CardButtonGroup
@ -26,7 +28,7 @@ fun ControlPage(viewModel: CarViewModel) {
val connectionState by viewModel.connectionState.collectAsState() val connectionState by viewModel.connectionState.collectAsState()
Column(LayoutContentModifier) { Column(LayoutContentModifier) {
PageTitle("控制") PageTitle(Route.Control.title)
if (connectionState == ConnectionState.CONNECTED) { if (connectionState == ConnectionState.CONNECTED) {
ControlPageStatusCard() ControlPageStatusCard()
} else { } else {
@ -49,7 +51,7 @@ fun ControlPageAutoControl() {
title = "自动控制", title = "自动控制",
buttons = listOf( buttons = listOf(
CardButtonItem( CardButtonItem(
text = "循迹模式", text = Route.ControlPathfinderMode.title,
icon = ImageVector.vectorResource(R.drawable.outline_route_24), icon = ImageVector.vectorResource(R.drawable.outline_route_24),
onClick = { onClick = {
Toast.makeText(context, "暂未实现", Toast.LENGTH_SHORT).show() Toast.makeText(context, "暂未实现", Toast.LENGTH_SHORT).show()
@ -62,16 +64,23 @@ fun ControlPageAutoControl() {
@Composable @Composable
fun ControlPageManualControl() { fun ControlPageManualControl() {
val context = LocalContext.current val context = LocalContext.current
val navController = LocalNavigation.current
CardButtonGroup( CardButtonGroup(
title = "手动控制", title = "手动控制",
buttons = listOf( buttons = listOf(
CardButtonItem( CardButtonItem(
text = "单摇杆模式", text = Route.ControlSingleJoystickMode.title,
icon = ImageVector.vectorResource(R.drawable.outline_joystick_24) icon = ImageVector.vectorResource(R.drawable.outline_joystick_24),
onClick = {
navController.navigate(Route.ControlSingleJoystickMode.route)
}
), ),
CardButtonItem( CardButtonItem(
text = "单向按键模式", text = Route.ControlGamepadMode.title,
icon = ImageVector.vectorResource(R.drawable.outline_gamepad_24) icon = ImageVector.vectorResource(R.drawable.outline_gamepad_24),
onClick = {
navController.navigate(Route.ControlGamepadMode.route)
}
), ),
CardButtonItem( CardButtonItem(
text = "陀螺仪模式", text = "陀螺仪模式",

View File

@ -1,8 +1,188 @@
package icu.fur93.esp32_car.page package icu.fur93.esp32_car.page
import android.view.MotionEvent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import icu.fur93.esp32_car.R
import icu.fur93.esp32_car.Route
import icu.fur93.esp32_car.entity.control.JoystickState
import icu.fur93.esp32_car.ui.carditem.StatusCardInfo
import icu.fur93.esp32_car.ui.carditem.StatusCardJoystickStatus
import icu.fur93.esp32_car.ui.carditem.StatusCardMotorStatus
import icu.fur93.esp32_car.ui.component.Joystick
import icu.fur93.esp32_car.ui.component.PageTitle
import icu.fur93.esp32_car.ui.component.StatusCard
import icu.fur93.esp32_car.ui.theme.LayoutContentModifier
import icu.fur93.esp32_car.viewmodel.CarViewModel import icu.fur93.esp32_car.viewmodel.CarViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable @Composable
fun ControlSingleJoystickModePage(viewModel: CarViewModel) { fun ControlSingleJoystickModePage(viewModel: CarViewModel) {
var joystickState by remember { mutableStateOf(JoystickState()) }
Column(LayoutContentModifier) {
PageTitle(Route.ControlSingleJoystickMode.title)
ControlSingleJoystickModeStatusCard(viewModel, joystickState)
ControlSingleJoystickModeControlPanel(viewModel) {
joystickState = it
}
}
}
@Composable
fun ControlSingleJoystickModeStatusCard(viewModel: CarViewModel, joystickState: JoystickState) {
val deviceInfo by viewModel.connectionInfoState.collectAsState()
val carState by viewModel.carState.collectAsState()
StatusCard(
cardItems = listOf(
StatusCardInfo(deviceInfo),
StatusCardMotorStatus(carState),
StatusCardJoystickStatus(joystickState)
)
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ControlSingleJoystickModeControlPanel(
viewModel: CarViewModel,
onJoystickStateChange: (JoystickState) -> Unit
) {
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
FilledIconButton(
onClick = {},
modifier = Modifier.pointerInteropFilter { event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
viewModel.sendXYR(0, 0, 50)
true
}
MotionEvent.ACTION_UP -> {
viewModel.stop()
true
}
else -> false
}
}
) {
Icon(ImageVector.vectorResource(R.drawable.rotate_left_24), "逆时针旋转")
}
ControlJoystick(
viewModel = viewModel,
onJoystickStateChange = onJoystickStateChange
)
FilledIconButton(
onClick = {},
modifier = Modifier.pointerInteropFilter { event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
viewModel.sendXYR(0, 0, -50)
true
}
MotionEvent.ACTION_UP -> {
viewModel.stop()
true
}
else -> false
}
}
) {
Icon(ImageVector.vectorResource(R.drawable.rotate_right_24), "顺时针旋转")
}
}
}
@Composable
fun ControlJoystick(
viewModel: CarViewModel,
onJoystickStateChange: (JoystickState) -> Unit
) {
// 记住上一次发送的状态和时间
val lastSentState = remember { mutableStateOf(JoystickState()) }
val lastSentTime = remember { mutableStateOf(0L) }
// 用于停止命令重发的计数器
val stopCommandCount = remember { mutableStateOf(0) }
val coroutineScope = rememberCoroutineScope()
Joystick(
size = 175.dp,
knobScale = 0.25f
) { state ->
// 更新状态
onJoystickStateChange(state)
val currentTime = System.currentTimeMillis()
// 检查是否是停止状态(摇杆回正)
val isStopCommand = state.x == 0f && state.y == 0f
// 如果是停止命令,启动重发机制
// 否则检查时间间隔和状态变化
if (isStopCommand) {
if (stopCommandCount.value == 0) {
// 第一次发送停止命令
viewModel.stop()
stopCommandCount.value = 1
// 启动延时重发
coroutineScope.launch {
delay(50) // 延时50ms
viewModel.stop() // 再次发送停止命令
stopCommandCount.value = 2
delay(50) // 再次延时50ms
viewModel.stop() // 第三次发送停止命令
stopCommandCount.value = 0 // 重置计数器
}
}
} else if ((state.x != lastSentState.value.x || state.y != lastSentState.value.y) &&
currentTime - lastSentTime.value >= 50
) {
// 非停止命令的正常处理
stopCommandCount.value = 0 // 重置停止命令计数器
val x = (state.x * 100).toInt()
val y = (state.y * 100).toInt()
viewModel.sendXYR(x, y, 0)
// 更新上次发送的状态和时间
lastSentState.value = state
lastSentTime.value = currentTime
}
}
} }

View File

@ -19,11 +19,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.navigation.NavHostController import icu.fur93.esp32_car.BuildConfig
import icu.fur93.esp32_car.R import icu.fur93.esp32_car.R
import icu.fur93.esp32_car.Route
import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl import icu.fur93.esp32_car.repository.BluetoothRepositoryImpl
import icu.fur93.esp32_car.repository.ConnectionState import icu.fur93.esp32_car.repository.ConnectionState
import icu.fur93.esp32_car.ui.component.PageTitle import icu.fur93.esp32_car.ui.component.PageTitle
import icu.fur93.esp32_car.ui.component.TipText
import icu.fur93.esp32_car.ui.dialog.BleDeviceScanDialog import icu.fur93.esp32_car.ui.dialog.BleDeviceScanDialog
import icu.fur93.esp32_car.ui.theme.LayoutContentPadding import icu.fur93.esp32_car.ui.theme.LayoutContentPadding
import icu.fur93.esp32_car.ui.theme.LayoutTopPadding import icu.fur93.esp32_car.ui.theme.LayoutTopPadding
@ -39,9 +41,16 @@ fun SettingsPage(viewModel: CarViewModel) {
end = LayoutContentPadding end = LayoutContentPadding
) )
) { ) {
PageTitle("设置") PageTitle(Route.Settings.title)
} }
SettingsList(viewModel) SettingsList(viewModel)
TipText("GitHub: colour93/esp32-car-android")
TipText("Build: ${
android.text.format.DateFormat.format(
"yyyy/MM/dd HH:mm:ss",
BuildConfig.BUILD_TIME.toLong()
)
}")
} }
} }

View File

@ -0,0 +1,6 @@
package icu.fur93.esp32_car.provider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.navigation.NavHostController
val LocalNavigation = staticCompositionLocalOf<NavHostController> { error("Not provided") }

View File

@ -7,12 +7,14 @@ import android.bluetooth.BluetoothDevice.TRANSPORT_LE
import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.content.Context import android.content.Context
import android.util.Log
import android.widget.Toast import android.widget.Toast
import icu.fur93.esp32_car.const.CarCommands import icu.fur93.esp32_car.const.CarCommands
import icu.fur93.esp32_car.entity.BleDevice import icu.fur93.esp32_car.entity.BleDevice
@ -189,15 +191,21 @@ class BluetoothRepositoryImpl(
} }
} }
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
super.onServicesDiscovered(gatt, status)
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
// 找到特定的服务和特征 gatt?.getService(UUID.fromString(serviceUUID))?.let { service ->
gatt.services?.forEach { service -> bluetoothGatt = gatt
service.characteristics?.forEach { characteristic -> rxCharacteristic =
// 这里需要根据你的设备具体的UUID来匹配 service.getCharacteristic(UUID.fromString(rxCharUUID))
if (characteristic.uuid == UUID.fromString(rxCharUUID)) {
rxCharacteristic = characteristic // 注册通知监听器
} val txCharacteristic =
service.getCharacteristic(UUID.fromString(txCharUUID))
gatt.setCharacteristicNotification(txCharacteristic, true)
txCharacteristic.descriptors.forEach { descriptor ->
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
} }
} }
} }
@ -208,6 +216,7 @@ class BluetoothRepositoryImpl(
characteristic: BluetoothGattCharacteristic, characteristic: BluetoothGattCharacteristic,
value: ByteArray value: ByteArray
) { ) {
super.onCharacteristicChanged(gatt, characteristic, value)
if (characteristic.uuid == UUID.fromString(txCharUUID)) { if (characteristic.uuid == UUID.fromString(txCharUUID)) {
onReceivePacket(value) onReceivePacket(value)
} }

View File

@ -2,17 +2,19 @@ package icu.fur93.esp32_car.ui.carditem
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import icu.fur93.esp32_car.entity.control.JoystickState
import icu.fur93.esp32_car.ui.component.CardItem import icu.fur93.esp32_car.ui.component.CardItem
import icu.fur93.esp32_car.ui.component.StatusCardItemText import icu.fur93.esp32_car.ui.component.StatusCardItemText
import icu.fur93.esp32_car.ui.component.format
@SuppressLint("ComposableNaming") @SuppressLint("ComposableNaming")
@Composable @Composable
fun StatusCardJoystickStatus () : CardItem { fun StatusCardJoystickStatus (joystickState: JoystickState) : CardItem {
return CardItem( return CardItem(
title = "摇杆状态", title = "摇杆状态",
content = { content = {
StatusCardItemText("X 0.00") StatusCardItemText("X ${joystickState.x.format(2)}")
StatusCardItemText("Y 0.00") StatusCardItemText("Y ${joystickState.y.format(2)}")
} }
) )
} }

View File

@ -17,11 +17,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import icu.fur93.esp32_car.Route import icu.fur93.esp32_car.Route
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.unit.dp
data class BottomNavigationItem( data class BottomNavigationItem(
val title: String,
val icon: ImageVector, val icon: ImageVector,
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val route: Route val route: Route
@ -29,29 +26,9 @@ data class BottomNavigationItem(
@Composable @Composable
fun BottomNavigationBar( fun BottomNavigationBar(
navController: NavHostController navController: NavHostController,
navItems: List<BottomNavigationItem>
) { ) {
val navItems = listOf(
BottomNavigationItem(
title = "主页",
icon = Icons.Outlined.Home,
selectedIcon = Icons.Default.Home,
route = Route.Home
),
BottomNavigationItem(
title = "控制",
icon = Icons.AutoMirrored.Outlined.Send,
selectedIcon = Icons.AutoMirrored.Filled.Send,
route = Route.Control
),
BottomNavigationItem(
title = "设置",
icon = Icons.Outlined.Settings,
selectedIcon = Icons.Default.Settings,
route = Route.Settings
)
)
val currentRoute = navController val currentRoute = navController
.currentBackStackEntryFlow .currentBackStackEntryFlow
.collectAsState(initial = navController.currentBackStackEntry) .collectAsState(initial = navController.currentBackStackEntry)
@ -66,13 +43,21 @@ fun BottomNavigationBar(
Icon( Icon(
if ((currentRoute if ((currentRoute
?: "").startsWith(item.route.route) ?: "").startsWith(item.route.route)
) item.selectedIcon else item.icon, item.title ) item.selectedIcon else item.icon, item.route.title
) )
}, },
label = { Text(item.title) }, label = { Text(item.route.title) },
selected = (currentRoute?:"").startsWith(item.route.route), selected = (currentRoute?:"").startsWith(item.route.route),
onClick = { onClick = {
navController.navigate(item.route.route) navController.navigate(item.route.route) {
// 弹出到导航图的起始位置
popUpTo(item.route.route) { inclusive = true }
launchSingleTop = true
// 避免创建同一目标的多个副本
launchSingleTop = true
// 恢复之前的状态
restoreState = false
}
} }
) )
} }

View File

@ -4,30 +4,24 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import icu.fur93.esp32_car.entity.control.JoystickState
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.hypot import kotlin.math.hypot
import kotlin.math.PI
// 摇杆数据类 // 摇杆数据类
data class JoystickState(
val x: Float = 0f, // -1 到 1
val y: Float = 0f, // -1 到 1
val angle: Float = 0f, // 角度(弧度)
val distance: Float = 0f // 0 到 1
)
@Composable @Composable
fun Joystick( fun Joystick(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Float = 200f, size: Dp = 200.dp,
knobScale: Float = 0.4f,
onJoystickMove: (JoystickState) -> Unit onJoystickMove: (JoystickState) -> Unit
) { ) {
// 状态管理 // 状态管理
@ -35,23 +29,23 @@ fun Joystick(
var stickPosition by remember { mutableStateOf(Offset.Zero) } var stickPosition by remember { mutableStateOf(Offset.Zero) }
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
// 计算实际尺寸
val radius = size / 2
val innerRadius = radius * 0.4f
val surfaceColor = MaterialTheme.colorScheme.surfaceDim val surfaceColor = MaterialTheme.colorScheme.surfaceDim
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
// 创建摇杆 // 创建摇杆
Canvas( Canvas(
modifier = modifier modifier = modifier
.size(size.dp) .size(size)
.padding(8.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
// 将 size 转换为像素
val sizePx = size.toPx()
val radius = sizePx / 2
val innerRadius = radius * knobScale
detectDragGestures( detectDragGestures(
onDragStart = { offset -> onDragStart = { offset ->
isDragging = true isDragging = true
centerPoint = Offset(size / 2, size / 2) centerPoint = Offset(sizePx / 2, sizePx / 2)
updateStickPosition(offset, centerPoint, radius, innerRadius) { newPos -> updateStickPosition(offset, centerPoint, radius, innerRadius) { newPos ->
stickPosition = newPos stickPosition = newPos
val state = calculateJoystickState(centerPoint, newPos, radius) val state = calculateJoystickState(centerPoint, newPos, radius)
@ -74,9 +68,14 @@ fun Joystick(
) )
} }
) { ) {
// 获取实际像素尺寸
val sizePx = size.toPx()
val radius = sizePx / 2
val innerRadius = radius * knobScale
// 初始化中心点 // 初始化中心点
if (centerPoint == Offset.Zero) { if (centerPoint == Offset.Zero) {
centerPoint = Offset(size / 2, size / 2) centerPoint = Offset(sizePx / 2, sizePx / 2)
stickPosition = centerPoint stickPosition = centerPoint
} }

View File

@ -0,0 +1,29 @@
package icu.fur93.esp32_car.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
@Composable()
fun TipText(
text: String
) {
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = text,
style = TextStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.tertiary
),
)
}
}

View File

@ -2,6 +2,7 @@ package icu.fur93.esp32_car.viewmodel
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import icu.fur93.esp32_car.const.CarCommands import icu.fur93.esp32_car.const.CarCommands

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="@android:color/white" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M7.11,8.53L5.7,7.11C4.8,8.27 4.24,9.61 4.07,11h2.02c0.14,-0.87 0.49,-1.72 1.02,-2.47zM6.09,13L4.07,13c0.17,1.39 0.72,2.73 1.62,3.89l1.41,-1.42c-0.52,-0.75 -0.87,-1.59 -1.01,-2.47zM7.1,18.32c1.16,0.9 2.51,1.44 3.9,1.61L11,17.9c-0.87,-0.15 -1.71,-0.49 -2.46,-1.03L7.1,18.32zM13,4.07L13,1L8.45,5.55 13,10L13,6.09c2.84,0.48 5,2.94 5,5.91s-2.16,5.43 -5,5.91v2.02c3.95,-0.49 7,-3.85 7,-7.93s-3.05,-7.44 -7,-7.93z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="@android:color/white" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M15.55,5.55L11,1v3.07C7.06,4.56 4,7.92 4,12s3.05,7.44 7,7.93v-2.02c-2.84,-0.48 -5,-2.94 -5,-5.91s2.16,-5.43 5,-5.91L11,10l4.55,-4.45zM19.93,11c-0.17,-1.39 -0.72,-2.73 -1.62,-3.89l-1.42,1.42c0.54,0.75 0.88,1.6 1.02,2.47h2.02zM13,17.9v2.02c1.39,-0.17 2.74,-0.71 3.9,-1.61l-1.44,-1.44c-0.75,0.54 -1.59,0.89 -2.46,1.03zM16.89,15.48l1.42,1.41c0.9,-1.16 1.45,-2.5 1.62,-3.89h-2.02c-0.14,0.87 -0.48,1.72 -1.02,2.48z"/>
</vector>