From b1a3c38a6909629b81d33470064bb7f151e64ae1 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Mon, 7 Sep 2026 13:37:34 +0800 Subject: [PATCH] android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued --- analysis/preview/preview_pose0_v6.png | 3 + analysis/preview/preview_pose0b_v6.png | 3 + analysis/preview/preview_pose180_v6.png | 3 + analysis/preview/preview_pose270_v6.png | 3 + analysis/preview/preview_pose90_v6.png | 3 + .../kotlin/com/mag160c/thermal/ui/AppRoot.kt | 18 +++++- .../mag160c/thermal/ui/DeviceOrientation.kt | 59 +++++++++++++++++++ .../mag160c/thermal/ui/live/LiveRenderer.kt | 29 +++++++-- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 25 +++++--- build-artifacts/mag160c-app-debug.apk | 4 +- docs/android_app/HANDOFF_DEVELOPMENT.md | 6 ++ docs/android_app/session_state.md | 16 +++++ 12 files changed, 155 insertions(+), 17 deletions(-) create mode 100644 analysis/preview/preview_pose0_v6.png create mode 100644 analysis/preview/preview_pose0b_v6.png create mode 100644 analysis/preview/preview_pose180_v6.png create mode 100644 analysis/preview/preview_pose270_v6.png create mode 100644 analysis/preview/preview_pose90_v6.png create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt diff --git a/analysis/preview/preview_pose0_v6.png b/analysis/preview/preview_pose0_v6.png new file mode 100644 index 0000000..f1f2c1f --- /dev/null +++ b/analysis/preview/preview_pose0_v6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3729c85023ad2db4d9dbcf3dbb137e4978d15b72979b9428e4bf20b9023496b +size 767935 diff --git a/analysis/preview/preview_pose0b_v6.png b/analysis/preview/preview_pose0b_v6.png new file mode 100644 index 0000000..f1f2c1f --- /dev/null +++ b/analysis/preview/preview_pose0b_v6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3729c85023ad2db4d9dbcf3dbb137e4978d15b72979b9428e4bf20b9023496b +size 767935 diff --git a/analysis/preview/preview_pose180_v6.png b/analysis/preview/preview_pose180_v6.png new file mode 100644 index 0000000..debe9e0 --- /dev/null +++ b/analysis/preview/preview_pose180_v6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ba6a9d5638222dc1c70d42a17c092d0e35dbd3bcfbdf3c806a2dba389c89f2d +size 762300 diff --git a/analysis/preview/preview_pose270_v6.png b/analysis/preview/preview_pose270_v6.png new file mode 100644 index 0000000..e03a80b --- /dev/null +++ b/analysis/preview/preview_pose270_v6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d3301d96fc4c4cc26fa35bee1e5866db1f8cadd0ca1d3f53616edd643303add +size 775704 diff --git a/analysis/preview/preview_pose90_v6.png b/analysis/preview/preview_pose90_v6.png new file mode 100644 index 0000000..4353a68 --- /dev/null +++ b/analysis/preview/preview_pose90_v6.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f5ab1a7a11974f638063711579b8dbbbbf65dcc3f76968609200ae98573329e +size 775296 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt index 1052ce5..45b4148 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -12,13 +12,17 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.mag160c.thermal.R @@ -38,12 +42,20 @@ private val TABS = listOf( /** * App shell. The activity is portrait-locked, so the bottom navigation bar * is glued to the phone's portrait bottom edge at all times (its absolute - * position never moves, however the phone is physically held). Tab content - * lives in a stable slot. + * position never moves, however the phone is physically held). Bar content + * (icons/text) is pre-rotated by the physical device orientation so it stays + * readable in any grip. Tab content lives in a stable slot. */ @Composable fun AppRoot() { var tab by rememberSaveable { mutableStateOf(0) } + val phi by DeviceOrientation.deg.collectAsState() + val ctx = LocalContext.current + + DisposableEffect(Unit) { + DeviceOrientation.start(ctx) + onDispose { DeviceOrientation.stop() } + } Box(modifier = Modifier.fillMaxSize()) { when (tab) { @@ -71,6 +83,8 @@ fun AppRoot() { NavigationBarItem( selected = tab == i, onClick = { tab = i }, + // pre-rotate so the item is upright in the current grip + modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, icon = { Icon(painterResource(t.icon), null) }, label = { Text(t.label) }, ) diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt new file mode 100644 index 0000000..03ab966 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/DeviceOrientation.kt @@ -0,0 +1,59 @@ +package com.mag160c.thermal.ui + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import kotlin.math.abs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Physical orientation of the phone relative to its portrait grip, from the + * accelerometer. [deg] is the CLOCKWISE rotation of the device as seen by + * the user: 0 = upright portrait, 90 = turned clockwise (portrait top edge + * points to the user's right, gravity along +X device), 180 = upside down, + * 270 = turned counter-clockwise. + * + * The activity is portrait-locked (composition glued to the phone frame, so + * the thermal image region always matches the lens direction). UI layers use + * [deg] to pre-rotate their icons/text by -deg so labels stay readable in + * whatever grip the phone is currently held. + */ +object DeviceOrientation : SensorEventListener { + private val _deg = MutableStateFlow(0) + + /** Physical CW rotation of the phone relative to the portrait grip: 0/90/180/270. */ + val deg: StateFlow = _deg + + private var sm: SensorManager? = null + + fun start(context: Context) { + if (sm != null) return + val m = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val sensor = m.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return + sm = m + m.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI) + } + + fun stop() { + sm?.unregisterListener(this) + sm = null + } + + override fun onSensorChanged(event: SensorEvent) { + val gx = event.values[0] + val gy = event.values[1] + // hysteresis: only switch pose when the dominant axis clearly wins, + // so ~45 deg in-between holds keep the previous reading + val next = when { + abs(gx) > abs(gy) + 2.5f -> if (gx > 0) 90 else 270 + abs(gy) > abs(gx) + 2.5f -> if (gy < 0) 0 else 180 + else -> _deg.value + } + if (next != _deg.value) _deg.value = next + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt index 8fff707..e59e66a 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt @@ -16,8 +16,10 @@ import android.view.SurfaceView * CW (3:4 vertical) into a fixed fitted rect inside the available area * (full screen minus the control top bar and the bottom navigation bar). * The image region does NOT move or rotate however the phone is physically - * held, so the on-screen area always matches the thermal lens direction; - * OSD text stays screen-horizontal so the labels are always readable. + * held, so the on-screen area always matches the thermal lens direction. + * OSD text (center temp / probe labels / color-bar numbers) is pre-rotated + * by the accelerometer-derived grip angle so labels stay readable in any + * grip; marker dots and the color-bar strip stay glued to the image. */ class LiveRenderer( private val surfaceView: SurfaceView, @@ -40,6 +42,10 @@ class LiveRenderer( } private val viewport = android.graphics.RectF() + /** OSD text compensation: pre-rotation so labels are upright in the current grip. */ + private val textRot: Float + get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat() + fun attach() { surfaceView.holder.addCallback(this) } @@ -147,7 +153,11 @@ class LiveRenderer( if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density + // pivot around the marker anchor: the label stays attached while upright + canvas.save() + canvas.rotate(textRot, cx, cy) canvas.drawText(text, tx, ty, textPaint) + canvas.restore() } private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) { @@ -169,8 +179,16 @@ class LiveRenderer( textPaint.color = Color.WHITE val maxT = "%.1f".format(state.maxTempC) val minT = "%.1f".format(state.minTempC) - canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint) - canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint) + val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2 + val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2 + canvas.save() + canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density) + canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint) + canvas.restore() + canvas.save() + canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize) + canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint) + canvas.restore() } private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { @@ -178,7 +196,10 @@ class LiveRenderer( val ox = viewport.left + 12f * density val oy = viewport.top + textPaint.textSize + 10f * density state.centerTempC?.let { + canvas.save() + canvas.rotate(textRot, ox, oy) canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint) + canvas.restore() } if (state.maxTraceOn) { drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高") diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index a5b4450..c51e5e8 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -31,6 +31,7 @@ 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.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext @@ -48,12 +49,15 @@ import android.view.SurfaceView * The activity is portrait-locked, so the whole composition (top bar / * image region / bottom bar) is glued to the phone's portrait frame and * never moves or rotates, however the phone is physically held — the on- - * screen area always matches the thermal lens direction. + * screen area always matches the thermal lens direction. The bars' icons + * and text are pre-rotated by the physical grip angle (DeviceOrientation) + * so they stay readable in any grip. */ @Composable fun LiveScreen(vm: LiveViewModel = viewModel()) { val state by vm.state.collectAsState() val context = LocalContext.current + val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState() var showPalette by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -84,7 +88,10 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { if (!state.connected) { Text( text = statusText(state), - modifier = Modifier.align(Alignment.Center).padding(16.dp), + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { rotationZ = -phi.toFloat() } + .padding(16.dp), ) } @@ -107,17 +114,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { IconButton(onClick = { vm.triggerFfc() }) { Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正") } } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) { Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}") } } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { Text( text = if (state.maxTraceOn) "追踪·开" else "追踪", style = MaterialTheme.typography.labelMedium, @@ -126,22 +133,22 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { modifier = Modifier.clickable { vm.toggleMaxTrace() }, ) } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { IconButton(onClick = { showPalette = true }) { Icon(painterResource(R.drawable.ic_palette), "调色板") } } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { IconButton(onClick = { vm.capturePhoto(context) }) { Icon(painterResource(R.drawable.ic_camera), "拍照") } } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { IconButton(onClick = { vm.toggleRecording(context) }) { Icon(painterResource(R.drawable.ic_record), "录像") } } - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { Text( text = Palettes.NAMES[state.paletteIndex], style = MaterialTheme.typography.labelMedium, diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 564ffec..9024b90 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:900c58d4d8138199318304a6afa35f66b47e2c982b391a6d826281e525654a39 -size 11806159 +oid sha256:f8168002febd300de25e27c9db84cb5b01b8228f18c679c8f05ffeace506043e +size 11863017 diff --git a/docs/android_app/HANDOFF_DEVELOPMENT.md b/docs/android_app/HANDOFF_DEVELOPMENT.md index 8c46639..2dc5144 100644 --- a/docs/android_app/HANDOFF_DEVELOPMENT.md +++ b/docs/android_app/HANDOFF_DEVELOPMENT.md @@ -51,6 +51,7 @@ android/app/src/main/kotlin/com/mag160c/thermal/ ├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸) ├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定) +├─ ui/DeviceOrientation.kt 加速度计→手机物理朝向(条栏/OSD 文字补偿旋转用) ├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白) ├─ ui/theme/Theme.kt M3 动态取色(Android12+) ├─ ui/live/ ★实时画面(最核心) @@ -105,6 +106,11 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形 永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、 中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终 与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。** +- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用 + 加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下 + Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ` + 原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条 + 几何仍钉死在图像上)。对话框与其他页签暂不补偿。 - **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。 - **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射: `fx=1-sy/120, fy=sx/160`)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index f77c820..d59ab62 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -138,6 +138,22 @@ - [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。 - 注:不要再改回 fullSensor/sensor 横屏;横竖屏适配类需求一律以"竖屏构图恒定"为准。 +## 用户反馈修复 第六轮(2026-09-07,横屏持机图标文字可读) + +- [x] **用户反馈**:竖屏锁定后,横过来拿手机时顶栏/底栏里的图标和文字不旋转(侧着)。 + 约定补全:**构图钉死竖屏框架不变**(条栏绝对位置+热像区域不动),但条栏内容 + (图标+文字)与 OSD 文字要按**物理持机朝向**补偿旋转,保持可读。 +- [x] 新增 `ui/DeviceOrientation.kt`:加速度计→手机相对竖屏的顺时针物理转角 + φ∈{0,90,180,270}(主轴判定+2.5m/s² 滞回;竖屏锁定下 Display.rotation 恒 0 不可用)。 +- [x] 顶栏 7 控制项/底部导航 4 项:`graphicsLayer rotationZ=-φ` 原位预旋转(布局不动)。 +- [x] 渲染器 OSD 文字(中心温/探针标注/色标最高最低数字)`canvas.rotate(-φ)` 绕锚点 + 旋转;标记圆点、色标条几何仍钉死在图像上。 +- [x] 模拟器实测(`adb emu sensor set acceleration` 驱动 4 姿态):四姿态下热像区域 + 完全一致,图标/文字按姿态正确补偿(analysis/preview/preview_pose*_v6.png); + 姿态复位 0° 后输出与第五轮逐字节一致(MD5 相同)。 +- [x] 单元测试全过;APK 已更新。 +- 注:对话框(调色板等)与其他页签内容未做补偿旋转(保持竖屏可读),如需再加。 + ## 待办 - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)