android: 顶栏/底栏位置固定+图像区域永不旋转(只转文字)+挖孔屏安全区适配+完整交接文档
This commit is contained in:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user