From 54bd378bf7402b3216a1e78d3cc214002bcaafcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=96=E5=8F=81?= Date: Wed, 25 Dec 2024 17:27:59 +0800 Subject: [PATCH] feat: debug window --- app/build.gradle.kts | 2 + .../icu/fur93/esp32_car/page/SettingsPage.kt | 47 +++-- .../fur93/esp32_car/viewmodel/CarViewModel.kt | 2 + .../icu/fur93/esp32_car/window/DebugWindow.kt | 198 ++++++++++++++++++ .../esp32_car/window/DebugWindowManager.kt | 98 +++++++++ app/src/main/res/drawable/resize_24.xml | 9 + gradle/libs.versions.toml | 4 + 7 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/icu/fur93/esp32_car/window/DebugWindow.kt create mode 100644 app/src/main/java/icu/fur93/esp32_car/window/DebugWindowManager.kt create mode 100644 app/src/main/res/drawable/resize_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbcdceb..fdb6329 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt b/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt index ba99b81..9cb41d7 100644 --- a/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt +++ b/app/src/main/java/icu/fur93/esp32_car/page/SettingsPage.kt @@ -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: ${ - android.text.format.DateFormat.format( - "yyyy/MM/dd HH:mm:ss", - BuildConfig.BUILD_TIME.toLong() - ) - }") + 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) } diff --git a/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt b/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt index 5579f9f..f523e32 100644 --- a/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt +++ b/app/src/main/java/icu/fur93/esp32_car/viewmodel/CarViewModel.kt @@ -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 // 用例 diff --git a/app/src/main/java/icu/fur93/esp32_car/window/DebugWindow.kt b/app/src/main/java/icu/fur93/esp32_car/window/DebugWindow.kt new file mode 100644 index 0000000..1b58e97 --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/window/DebugWindow.kt @@ -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 +) { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/icu/fur93/esp32_car/window/DebugWindowManager.kt b/app/src/main/java/icu/fur93/esp32_car/window/DebugWindowManager.kt new file mode 100644 index 0000000..7154397 --- /dev/null +++ b/app/src/main/java/icu/fur93/esp32_car/window/DebugWindowManager.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/resize_24.xml b/app/src/main/res/drawable/resize_24.xml new file mode 100644 index 0000000..168ef31 --- /dev/null +++ b/app/src/main/res/drawable/resize_24.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5411d9c..f9bc2e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }