feat: debug window
This commit is contained in:
parent
4ab4487197
commit
54bd378bf7
|
@ -73,6 +73,8 @@ dependencies {
|
|||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.accompanist.systemuicontroller)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.savedstate.ktx)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package icu.fur93.esp32_car.page
|
||||
|
||||
import DebugWindowManager
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
@ -18,6 +19,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import icu.fur93.esp32_car.BuildConfig
|
||||
import icu.fur93.esp32_car.R
|
||||
|
@ -45,17 +47,33 @@ fun SettingsPage(viewModel: CarViewModel) {
|
|||
}
|
||||
SettingsList(viewModel)
|
||||
TipText("GitHub: colour93/esp32-car-android")
|
||||
TipText("Build: ${
|
||||
TipText(
|
||||
"Build: ${
|
||||
android.text.format.DateFormat.format(
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
BuildConfig.BUILD_TIME.toLong()
|
||||
)
|
||||
}")
|
||||
}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsList(viewModel: CarViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var debugMode by remember { mutableStateOf(DebugWindowManager.isShowing()) }
|
||||
|
||||
val onDebugModeChange = { enable: Boolean ->
|
||||
if (enable) {
|
||||
DebugWindowManager.show(context, viewModel)
|
||||
} else {
|
||||
DebugWindowManager.hide(context)
|
||||
}
|
||||
debugMode = enable
|
||||
}
|
||||
|
||||
// 使用 LazyColumn 显示设置列表
|
||||
LazyColumn {
|
||||
item {
|
||||
SettingConnectDeviceItem(viewModel)
|
||||
|
@ -65,7 +83,10 @@ fun SettingsList(viewModel: CarViewModel) {
|
|||
headlineContent = { Text("设备名称") },
|
||||
supportingContent = { Text("当前名称: Name") },
|
||||
trailingContent = {
|
||||
Icon(ImageVector.vectorResource(R.drawable.arrow_right_24), "设备名称")
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.arrow_right_24),
|
||||
contentDescription = "设备名称"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -74,10 +95,8 @@ fun SettingsList(viewModel: CarViewModel) {
|
|||
headlineContent = { Text("调试模式") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = false,
|
||||
onCheckedChange = {
|
||||
|
||||
}
|
||||
checked = debugMode,
|
||||
onCheckedChange = onDebugModeChange
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -86,13 +105,17 @@ fun SettingsList(viewModel: CarViewModel) {
|
|||
ListItem(
|
||||
headlineContent = { Text("PID 参数") },
|
||||
trailingContent = {
|
||||
Icon(ImageVector.vectorResource(R.drawable.arrow_right_24), "PID 参数")
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.arrow_right_24),
|
||||
contentDescription = "PID 参数"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun SettingConnectDeviceItem(viewModel: CarViewModel) {
|
||||
var showScanDialog by remember { mutableStateOf(false) }
|
||||
|
|
|
@ -3,6 +3,7 @@ package icu.fur93.esp32_car.viewmodel
|
|||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import icu.fur93.esp32_car.const.CarCommands
|
||||
|
@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import android.view.WindowManager
|
||||
|
||||
|
||||
// 用例
|
||||
|
|
|
@ -0,0 +1,198 @@
|
|||
package icu.fur93.esp32_car.window
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import icu.fur93.esp32_car.R
|
||||
import icu.fur93.esp32_car.entity.LogDirection
|
||||
import icu.fur93.esp32_car.entity.LogEntry
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun DebugWindow(
|
||||
onDrag: (Float, Float) -> Unit,
|
||||
onResize: (Float, Float) -> Unit,
|
||||
logs: List<LogEntry>
|
||||
) {
|
||||
var logFilter by remember { mutableStateOf(LogFilter.ALL) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(280.dp)
|
||||
.height(200.dp)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
// 顶部拖动条
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
.background(
|
||||
color = Color.DarkGray,
|
||||
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
onDrag(dragAmount.x, dragAmount.y)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Debug Window",
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤选项
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.DarkGray.copy(alpha = 0.3f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
FilterChip(
|
||||
selected = logFilter == LogFilter.ALL,
|
||||
onClick = { logFilter = LogFilter.ALL },
|
||||
label = { Text("全部", color = Color.White) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
FilterChip(
|
||||
selected = logFilter == LogFilter.SEND,
|
||||
onClick = { logFilter = LogFilter.SEND },
|
||||
label = { Text("发送", color = Color.White) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
FilterChip(
|
||||
selected = logFilter == LogFilter.RECEIVE,
|
||||
onClick = { logFilter = LogFilter.RECEIVE },
|
||||
label = { Text("接收", color = Color.White) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 日志列表
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
reverseLayout = true // 最新的日志显示在顶部
|
||||
) {
|
||||
items(
|
||||
logs.filter {
|
||||
when (logFilter) {
|
||||
LogFilter.ALL -> true
|
||||
LogFilter.SEND -> it.direction == LogDirection.SEND
|
||||
LogFilter.RECEIVE -> it.direction == LogDirection.RECEIVE
|
||||
}
|
||||
}.reversed()
|
||||
) { log ->
|
||||
LogItem(log)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右下角调整大小的手柄
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
onResize(dragAmount.x, dragAmount.y)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.resize_24),
|
||||
contentDescription = "Resize",
|
||||
tint = Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogItem(log: LogEntry) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 方向指示
|
||||
Text(
|
||||
text = if (log.direction == LogDirection.SEND) "→" else "←",
|
||||
color = if (log.direction == LogDirection.SEND)
|
||||
Color(0xFF4CAF50) else Color(0xFF2196F3),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.width(16.dp)
|
||||
)
|
||||
// 日志内容
|
||||
Text(
|
||||
text = log.data,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// 时间戳
|
||||
Text(
|
||||
text = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
.format(log.timestamp),
|
||||
color = Color.Gray,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class LogFilter {
|
||||
ALL, SEND, RECEIVE
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import icu.fur93.esp32_car.window.DebugWindow
|
||||
import icu.fur93.esp32_car.viewmodel.CarViewModel
|
||||
|
||||
object DebugWindowManager {
|
||||
private var debugWindow: ComposeView? = null
|
||||
private var layoutParams: WindowManager.LayoutParams? = null
|
||||
|
||||
// 添加最小尺寸限制
|
||||
private const val MIN_WIDTH_DP = 200
|
||||
private const val MIN_HEIGHT_DP = 150
|
||||
|
||||
fun show(context: Context, viewModel: CarViewModel) {
|
||||
if (debugWindow != null) return
|
||||
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val activity = context as? ComponentActivity ?: return
|
||||
|
||||
val density = context.resources.displayMetrics.density
|
||||
val width = (280 * density).toInt()
|
||||
val height = (200 * density).toInt()
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
width,
|
||||
height,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
x = 100
|
||||
y = 100
|
||||
}
|
||||
|
||||
val window = ComposeView(context).apply {
|
||||
setViewTreeLifecycleOwner(activity)
|
||||
setViewTreeSavedStateRegistryOwner(activity)
|
||||
|
||||
setContent {
|
||||
val logs by viewModel.logs.collectAsState()
|
||||
|
||||
DebugWindow(
|
||||
onDrag = { offsetX, offsetY ->
|
||||
params.x += offsetX.toInt()
|
||||
params.y += offsetY.toInt()
|
||||
windowManager.updateViewLayout(this, params)
|
||||
},
|
||||
onResize = { offsetX, offsetY ->
|
||||
val density = context.resources.displayMetrics.density
|
||||
val minWidth = (MIN_WIDTH_DP * density).toInt()
|
||||
val minHeight = (MIN_HEIGHT_DP * density).toInt()
|
||||
params.width = (params.width + offsetX.toInt()).coerceAtLeast(minWidth)
|
||||
params.height = (params.height + offsetY.toInt()).coerceAtLeast(minHeight)
|
||||
windowManager.updateViewLayout(this, params)
|
||||
},
|
||||
logs = logs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
windowManager.addView(window, params)
|
||||
debugWindow = window
|
||||
layoutParams = params
|
||||
}
|
||||
|
||||
fun hide(context: Context) {
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
debugWindow?.let {
|
||||
windowManager.removeView(it)
|
||||
debugWindow = null
|
||||
layoutParams = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isShowing(): Boolean {
|
||||
return debugWindow != null
|
||||
}
|
||||
|
||||
fun updatePosition(context: Context, x: Int, y: Int) {
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
layoutParams?.let { params ->
|
||||
params.x = x
|
||||
params.y = y
|
||||
debugWindow?.let {
|
||||
windowManager.updateViewLayout(it, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M22,22H20V20H22V22M22,18H20V16H22V18M18,22H16V20H18V22M18,18H16V16H18V18M14,22H12V20H14V22M22,14H20V12H22V14Z"/>
|
||||
</vector>
|
|
@ -15,6 +15,8 @@ datastorePreferencesCoreJvm = "1.1.1"
|
|||
datastorePreferences = "1.1.1"
|
||||
lifecycleViewmodelCompose = "2.9.0-alpha08"
|
||||
accompanistSystemuicontroller = "0.36.0"
|
||||
lifecycleRuntimeKtxVersion = "2.7.0"
|
||||
savedstateKtx = "1.2.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
@ -38,6 +40,8 @@ androidx-datastore-preferences-core-jvm = { group = "androidx.datastore", name =
|
|||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version = "2.7.0" }
|
||||
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
|
||||
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtxVersion" }
|
||||
androidx-savedstate-ktx = { group = "androidx.savedstate", name = "savedstate-ktx", version.ref = "savedstateKtx" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
Loading…
Reference in New Issue