feat: basic control
This commit is contained in:
parent
d0fe216097
commit
4ab4487197
|
@ -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"
|
||||||
|
|
|
@ -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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -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 = "单向按键模式"
|
||||||
|
)
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
)
|
|
@ -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 = "陀螺仪模式",
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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") }
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)}")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue