android: 顶栏/底栏位置固定+图像区域永不旋转(只转文字)+挖孔屏安全区适配+完整交接文档

This commit is contained in:
ZXCLI
2026-09-07 12:59:26 +08:00
parent 798e180683
commit 2c38537303
7 changed files with 251 additions and 173 deletions
@@ -1,10 +1,7 @@
package com.mag160c.thermal.ui
import android.content.res.Configuration
import androidx.compose.foundation.clickable
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.padding
@@ -22,7 +19,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R
@@ -40,86 +36,45 @@ private val TABS = listOf(
)
/**
* App shell with an overlay navigation that adapts to orientation without
* recreating the tab content: bottom bar in portrait, left rail in landscape.
* App shell. The bottom navigation bar is always at the bottom in both
* orientations (top/bottom bars keep their positions; only their inner
* icons/text follow the screen). Tab content lives in a stable slot.
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val isLandscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(modifier = Modifier.fillMaxSize()) {
// ---- tab content (always the same slot; nav is an overlay) ----
val bottomPad = if (isLandscape) 0.dp else 84.dp
when (tab) {
0 -> LiveScreen()
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
}
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
SettingsScreen()
}
}
// ---- overlay navigation ----
if (isLandscape) {
// rail overlays the left letterbox margin: no bottom inset needed
UiInsets.navPx = 0
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.align(Alignment.CenterStart)
.onSizeChanged { },
) {
Column(
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TABS.forEachIndexed { i, t ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable { tab = i }
.padding(vertical = 14.dp, horizontal = 8.dp),
) {
Icon(
painterResource(t.icon),
contentDescription = t.label,
tint = if (tab == i) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
t.label,
style = MaterialTheme.typography.labelSmall,
color = if (tab == i) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { UiInsets.navPx = it.height },
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
}
// bottom navigation overlay (both orientations)
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { UiInsets.navPx = it.height },
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
}
}
}
}
}
}
@@ -11,11 +11,11 @@ import android.view.SurfaceView
/**
* Software canvas renderer for the live IR stream.
*
* The image CONTENT stays fixed in the world while the phone rotates: the
* draw rotation tracks the display rotation (LiveViewModel.drawRotationDeg).
* Available area = full canvas minus the control-bar (top) and nav (bottom)
* insets reported via [LiveViewModel.uiTopPx]/[uiBottomPx]. OSD icons/text
* stay upright in the current screen orientation.
* The thermal image is ALWAYS drawn rotated 90 deg 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 with the phone; only the OSD text stays screen-horizontal so the
* labels are always readable.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -90,34 +90,20 @@ class LiveRenderer(
val availH = bottom - top
if (availH <= 0) return
val d = vm.drawRotationDeg()
val swap = d == 90 || d == 270
// fit the DISPLAYED (rotated) image into the available area
var dstW: Float
var dstH: Float
if (swap) {
dstW = availW
dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
} else {
// fit the 3:4 (rotated) image into the available rect (fixed orientation)
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 4f / 3f
if (dstW > availW) {
dstW = availW
dstH = availW * 3f / 4f
}
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
// draw the source bitmap rotated 90 deg CW around the viewport center
val cx = viewport.centerX()
val cy = viewport.centerY()
val w0 = if (swap) dstH else dstW // pre-rotation draw rect
val h0 = if (swap) dstW else dstH
val zoom = vm.state.value.zoom
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
@@ -126,7 +112,10 @@ class LiveRenderer(
} else null
canvas.save()
canvas.rotate(d.toFloat(), cx, cy)
canvas.rotate(90f, cx, cy)
// pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
val w0 = dstH
val h0 = dstW
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
@@ -198,4 +187,4 @@ class LiveRenderer(
}
drawColorBar(canvas, state)
}
}
}
@@ -4,10 +4,15 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@@ -36,7 +41,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView
import android.view.Surface
/**
* Stable single-branch live view: one SurfaceView for the whole screen,
@@ -60,14 +64,6 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
}
}
// keep the renderer in sync with the physical display rotation
val rotation = remember(context) {
context.display?.rotation ?: Surface.ROTATION_0
}
LaunchedEffect(rotation) {
vm.uiRotation = rotation
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidSurface(vm)
Box(
@@ -90,12 +86,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
)
}
// ---- control TOP bar (always visible, both orientations) ----
// ---- control TOP bar (always at top, both orientations) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
// cutout / status-bar safe area (punch-hole phones)
.windowInsetsPadding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Top),
)
.onSizeChanged { vm.uiTopPx = it.height },
) {
Row(
@@ -53,10 +53,6 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
@Volatile
var uiBottomPx: Int = 0
/** Display rotation (Surface.ROTATION_0/90/180/270), drives image orientation. */
@Volatile
var uiRotation: Int = 0
private val sessionListener = object : IrSession.Listener {
override fun onStateChanged(state: IrSession.State, message: String?) {
_state.value = _state.value.copy(
@@ -242,9 +238,9 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Rotation-aware: the image draw rotation follows the
* display rotation (same mapping as LiveRenderer), insets carve the
* available area.
* tapping near it. The image is always displayed rotated 90 deg CW
* (3:4 vertical) and does NOT move with the phone; only OSD text follows
* the screen. Insets carve the available area.
*/
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value
@@ -252,41 +248,23 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val top = uiTopPx.toFloat()
val bottom = viewH - uiBottomPx.toFloat()
if (screenY < top || screenY > bottom) return
val d = drawRotationDeg()
val swap = d == 90 || d == 270
// fit rect of the DISPLAYED (rotated) image inside the avail rect
// fit the 3:4 (rotated 90 CW) image into the available rect
val availW = viewW
val availH = bottom - top
var dstW: Float
var dstH: Float
if (swap) {
dstW = availW
dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
} else {
var dstW = availW
var dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 4f / 3f
if (dstW > availW) {
dstW = availW
dstH = availW * 3f / 4f
}
dstW = availH * 3f / 4f
}
val left = (availW - dstW) / 2f
val top2 = top + (availH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
val fx = (screenX - left) / dstW
val fy = (screenY - top2) / dstH
val sx: Float
val sy: Float
when (d) {
0 -> { sx = fx * 160f; sy = fy * 120f }
90 -> { sy = (1f - fx) * 120f; sx = fy * 160f }
180 -> { sx = (1f - fx) * 160f; sy = (1f - fy) * 120f }
else -> { sx = (1f - fy) * 160f; sy = fx * 120f }
}
// inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160
val sy = (1f - fx) * 120f
val sx = fy * 160f
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f
val existing = s.probes.firstOrNull { p ->
@@ -310,45 +288,21 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
refreshTemps()
}
/** Image draw rotation (deg CW) from the current display rotation. */
fun drawRotationDeg(): Int = ((90 - uiRotation * 90) % 360 + 360) % 360
/** Screen coords of a sensor point under the current rotation + insets. */
/** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */
fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val viewH = uiViewH.toFloat().coerceAtLeast(1f)
val top = uiTopPx.toFloat()
val availH = viewH - uiTopPx - uiBottomPx
val d = drawRotationDeg()
val swap = d == 90 || d == 270
var dstW: Float
var dstH: Float
if (swap) {
dstW = viewW
dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
} else {
val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
var dstW = viewW
var dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 4f / 3f
if (dstW > viewW) {
dstW = viewW
dstH = viewW * 3f / 4f
}
dstW = availH * 3f / 4f
}
val left = (viewW - dstW) / 2f
val top2 = uiTopPx + (availH - dstH) / 2f
val fx: Float
val fy: Float
when (d) {
0 -> { fx = sx / 160f; fy = sy / 120f }
90 -> { fx = 1f - sy / 120f; fy = sx / 160f }
180 -> { fx = 1f - sx / 160f; fy = 1f - sy / 120f }
else -> { fx = sy / 120f; fy = 1f - sx / 160f }
}
return floatArrayOf(left + fx * dstW, top2 + fy * dstH)
val top = uiTopPx + (availH - dstH) / 2f
val fx = 1f - sy / 120f
val fy = sx / 160f
return floatArrayOf(left + fx * dstW, top + fy * dstH)
}
/** Last known surface size, for probe screen mapping. */