android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,13 +12,17 @@ import androidx.compose.material3.NavigationBarItem
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.mag160c.thermal.R
|
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
|
* 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
|
* 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
|
* position never moves, however the phone is physically held). Bar content
|
||||||
* lives in a stable slot.
|
* (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
|
@Composable
|
||||||
fun AppRoot() {
|
fun AppRoot() {
|
||||||
var tab by rememberSaveable { mutableStateOf(0) }
|
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()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
@@ -71,6 +83,8 @@ fun AppRoot() {
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = tab == i,
|
selected = tab == i,
|
||||||
onClick = { 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) },
|
icon = { Icon(painterResource(t.icon), null) },
|
||||||
label = { Text(t.label) },
|
label = { Text(t.label) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<Int> = _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
|
||||||
|
}
|
||||||
@@ -16,8 +16,10 @@ import android.view.SurfaceView
|
|||||||
* CW (3:4 vertical) into a fixed fitted rect inside the available area
|
* 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).
|
* (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
|
* 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;
|
* held, so the on-screen area always matches the thermal lens direction.
|
||||||
* OSD text stays screen-horizontal so the labels are always readable.
|
* 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(
|
class LiveRenderer(
|
||||||
private val surfaceView: SurfaceView,
|
private val surfaceView: SurfaceView,
|
||||||
@@ -40,6 +42,10 @@ class LiveRenderer(
|
|||||||
}
|
}
|
||||||
private val viewport = android.graphics.RectF()
|
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() {
|
fun attach() {
|
||||||
surfaceView.holder.addCallback(this)
|
surfaceView.holder.addCallback(this)
|
||||||
}
|
}
|
||||||
@@ -147,7 +153,11 @@ class LiveRenderer(
|
|||||||
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
|
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
|
||||||
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
|
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
|
||||||
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * 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.drawText(text, tx, ty, textPaint)
|
||||||
|
canvas.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
|
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
|
||||||
@@ -169,8 +179,16 @@ class LiveRenderer(
|
|||||||
textPaint.color = Color.WHITE
|
textPaint.color = Color.WHITE
|
||||||
val maxT = "%.1f".format(state.maxTempC)
|
val maxT = "%.1f".format(state.maxTempC)
|
||||||
val minT = "%.1f".format(state.minTempC)
|
val minT = "%.1f".format(state.minTempC)
|
||||||
canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint)
|
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
|
||||||
canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
|
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) {
|
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
|
||||||
@@ -178,7 +196,10 @@ class LiveRenderer(
|
|||||||
val ox = viewport.left + 12f * density
|
val ox = viewport.left + 12f * density
|
||||||
val oy = viewport.top + textPaint.textSize + 10f * density
|
val oy = viewport.top + textPaint.textSize + 10f * density
|
||||||
state.centerTempC?.let {
|
state.centerTempC?.let {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(textRot, ox, oy)
|
||||||
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
|
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
|
||||||
|
canvas.restore()
|
||||||
}
|
}
|
||||||
if (state.maxTraceOn) {
|
if (state.maxTraceOn) {
|
||||||
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高")
|
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高")
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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 /
|
* The activity is portrait-locked, so the whole composition (top bar /
|
||||||
* image region / bottom bar) is glued to the phone's portrait frame and
|
* 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-
|
* 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
|
@Composable
|
||||||
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState()
|
||||||
var showPalette by remember { mutableStateOf(false) }
|
var showPalette by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -84,7 +88,10 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
if (!state.connected) {
|
if (!state.connected) {
|
||||||
Text(
|
Text(
|
||||||
text = statusText(state),
|
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),
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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() }) {
|
IconButton(onClick = { vm.triggerFfc() }) {
|
||||||
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
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) }) {
|
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
|
||||||
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
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(
|
||||||
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -126,22 +133,22 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
modifier = Modifier.clickable { vm.toggleMaxTrace() },
|
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 }) {
|
IconButton(onClick = { showPalette = true }) {
|
||||||
Icon(painterResource(R.drawable.ic_palette), "调色板")
|
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) }) {
|
IconButton(onClick = { vm.capturePhoto(context) }) {
|
||||||
Icon(painterResource(R.drawable.ic_camera), "拍照")
|
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) }) {
|
IconButton(onClick = { vm.toggleRecording(context) }) {
|
||||||
Icon(painterResource(R.drawable.ic_record), "录像")
|
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(
|
||||||
text = Palettes.NAMES[state.paletteIndex],
|
text = Palettes.NAMES[state.paletteIndex],
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
Binary file not shown.
@@ -51,6 +51,7 @@
|
|||||||
android/app/src/main/kotlin/com/mag160c/thermal/
|
android/app/src/main/kotlin/com/mag160c/thermal/
|
||||||
├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸)
|
├─ MainActivity.kt 启动,enableEdgeToEdge + 隐藏状态栏(沉浸)
|
||||||
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定)
|
├─ ui/AppRoot.kt App壳:底部导航(实时/相册/分析/设置)恒贴竖屏底边(Activity 竖屏锁定)
|
||||||
|
├─ ui/DeviceOrientation.kt 加速度计→手机物理朝向(条栏/OSD 文字补偿旋转用)
|
||||||
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
|
├─ ui/UiInsets.kt 单例:底部导航高度 px(渲染器用它留白)
|
||||||
├─ ui/theme/Theme.kt M3 动态取色(Android12+)
|
├─ ui/theme/Theme.kt M3 动态取色(Android12+)
|
||||||
├─ ui/live/ ★实时画面(最核心)
|
├─ ui/live/ ★实时画面(最核心)
|
||||||
@@ -105,6 +106,11 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
|
|||||||
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
|
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
|
||||||
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
|
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
|
||||||
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
|
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
|
||||||
|
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
|
||||||
|
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
|
||||||
|
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
|
||||||
|
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
|
||||||
|
几何仍钉死在图像上)。对话框与其他页签暂不补偿。
|
||||||
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
|
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
|
||||||
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
|
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
|
||||||
`fx=1-sy/120, fy=sx/160`)。
|
`fx=1-sy/120, fy=sx/160`)。
|
||||||
|
|||||||
@@ -138,6 +138,22 @@
|
|||||||
- [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。
|
- [x] 单元测试全过;APK 已更新 build-artifacts/mag160c-app-debug.apk。
|
||||||
- 注:不要再改回 fullSensor/sensor 横屏;横竖屏适配类需求一律以"竖屏构图恒定"为准。
|
- 注:不要再改回 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保存端到端)
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
|
||||||
|
|||||||
Reference in New Issue
Block a user