android: 竖屏旋转热像图填满屏宽/横屏原生方向+控制栏横屏移至右侧+大号圆圈标记与色标+fullSensor方向跟随

This commit is contained in:
ZXCLI
2026-09-07 00:29:16 +08:00
parent 17fcb7b94c
commit 8d22969f6d
8 changed files with 393 additions and 263 deletions
Binary file not shown.
Binary file not shown.
+1
View File
@@ -16,6 +16,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="fullSensor"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true"> android:resizeableActivity="true">
<intent-filter> <intent-filter>
@@ -1,17 +1,18 @@
package com.mag160c.thermal.ui package com.mag160c.thermal.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail import androidx.compose.material3.Surface
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,56 +38,81 @@ private val TABS = listOf(
Tab("设置", R.drawable.ic_settings), Tab("设置", R.drawable.ic_settings),
) )
/**
* App shell with an overlay navigation that adapts to orientation without
* recreating the tab content: bottom bar in portrait, left rail in landscape.
*/
@Composable @Composable
fun AppRoot() { fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) } var tab by rememberSaveable { mutableStateOf(0) }
val isLandscape = val isLandscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val content: @Composable () -> Unit = { 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) { when (tab) {
0 -> LiveScreen() 0 -> LiveScreen()
1 -> GalleryScreen() 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
2 -> GalleryScreen() GalleryScreen()
else -> SettingsScreen() }
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
GalleryScreen()
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
SettingsScreen()
}
} }
}
if (isLandscape) { // ---- overlay navigation ----
Row(modifier = Modifier.fillMaxSize()) { if (isLandscape) {
NavigationRail { Surface(
TABS.forEachIndexed { i, t -> color = MaterialTheme.colorScheme.surface,
NavigationRailItem( modifier = Modifier.align(Alignment.CenterStart),
selected = tab == i, ) {
onClick = { tab = i }, Column(
icon = { Icon(painterResource(t.icon), null) }, modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
label = { Text(t.label) }, 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,
)
}
}
} }
} }
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() } } else {
} Surface(
} else { color = MaterialTheme.colorScheme.surface,
Scaffold(bottomBar = { modifier = Modifier.align(Alignment.BottomCenter),
NavigationBar { ) {
TABS.forEachIndexed { i, t -> NavigationBar(modifier = Modifier.fillMaxWidth()) {
NavigationBarItem( TABS.forEachIndexed { i, t ->
selected = tab == i, NavigationBarItem(
onClick = { tab = i }, selected = tab == i,
icon = { Icon(painterResource(t.icon), null) }, onClick = { tab = i },
label = { Text(t.label) }, icon = { Icon(painterResource(t.icon), null) },
) label = { Text(t.label) },
)
}
} }
} }
}) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) { content() }
} }
} }
} }
@Composable
private fun Placeholder(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -9,9 +9,13 @@ import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
/** /**
* Software canvas renderer for the live IR stream. Mirrors the official * Software canvas renderer for the live IR stream.
* apps' drawImage: letterbox the 320x240 frame into the 4:3 area, apply *
* digital zoom, and overlay the temperature OSD. * The image always fills the display area as much as possible:
* portrait -> the 320x240 frame is rotated 90 deg (drawn 240x320, fills
* the screen width)
* landscape -> native 4:3, fills the screen height
* OSD icons/text stay upright in the current screen orientation.
*/ */
class LiveRenderer( class LiveRenderer(
private val surfaceView: SurfaceView, private val surfaceView: SurfaceView,
@@ -19,15 +23,23 @@ class LiveRenderer(
) : SurfaceHolder.Callback, Runnable { ) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false) private val running = java.util.concurrent.atomic.AtomicBoolean(false)
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val rotated = Bitmap.createBitmap(240, 320, Bitmap.Config.ARGB_8888)
private val px = IntArray(320 * 240)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG) private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE color = Color.WHITE
typeface = Typeface.SANS_SERIF typeface = Typeface.SANS_SERIF
textSize = 14f textSize = 15f * density
setShadowLayer(2f, 0f, 0f, Color.BLACK) setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
} }
private val viewport = android.graphics.RectF() private val viewport = android.graphics.RectF()
private var isPortraitView = true
fun attach() { fun attach() {
surfaceView.holder.addCallback(this) surfaceView.holder.addCallback(this)
@@ -67,82 +79,113 @@ class LiveRenderer(
} }
} }
/** true when the frame must be rotated 90 (portrait view). */
private val rotationNeeded: Boolean get() = isPortraitView
/** Screen position of a sensor point (x in 0..159, y in 0..119). */
fun sensorToScreen(sx: Int, sy: Int): FloatArray {
val w = 160f
val h = 120f
if (rotationNeeded) {
// dst_x = 1 - sy/120 ; dst_y = sx/160 (normalized on the rotated rect)
val fx = 1f - sy / h
val fy = sx / w
return floatArrayOf(viewport.left + fx * viewport.width(), viewport.top + fy * viewport.height())
}
val fx = sx / w
val fy = sy / h
return floatArrayOf(viewport.left + fx * viewport.width(), viewport.top + fy * viewport.height())
}
private fun drawFrame(canvas: Canvas) { private fun drawFrame(canvas: Canvas) {
val w = canvas.width.toFloat() val w = canvas.width.toFloat()
val h = canvas.height.toFloat() val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK) canvas.drawColor(Color.BLACK)
isPortraitView = h > w
val frame = vm.latestFrame val frame = vm.latestFrame
if (frame == null) { if (frame == null) return
drawIdle(canvas, w, h)
return
}
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
// letterbox 4:3 // build displayed bitmap (rotated in portrait)
val ar = 4f / 3f if (rotationNeeded) {
val viewRatio = w / h bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val dstW: Float bitmap.getPixels(px, 0, 320, 0, 0, 320, 240)
val dstH: Float val rpx = IntArray(240 * 320)
if (viewRatio > ar) { for (y in 0 until 240) {
dstH = h for (x in 0 until 320) {
dstW = h * ar // 90 deg CW: dst(x',y') = src(y, 239-x) -> with sizes: dst(240x320)
} else { // dst index: x' = 239 - src_y, y' = src_x
dstW = w val srcIdx = y * 320 + x
dstH = w / ar val dstX = 239 - y
} val dstY = x
val left = (w - dstW) / 2f rpx[dstY * 240 + dstX] = px[srcIdx]
val top = (h - dstH) / 2f }
viewport.set(left, top, left + dstW, top + dstH) }
rotated.setPixels(rpx, 0, 240, 0, 0, 240, 320)
val zoom = vm.state.value.zoom // fit 3:4 (240x320) into the portrait view
val srcRect = if (zoom > 1) { val dstW = w
val cw = 320 / zoom val dstH = w * 320f / 240f
val ch = 240 / zoom val left = 0f
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2) val top = (h - dstH) / 2f
} else null viewport.set(left, top, left + dstW, top + dstH)
canvas.drawBitmap(rotated, null, viewport, paint)
if (srcRect != null) {
canvas.drawBitmap(bitmap, srcRect, viewport, paint)
} else { } else {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val ar = 4f / 3f
val viewRatio = w / h
val dstW: Float
val dstH: Float
if (viewRatio > ar) {
dstH = h
dstW = h * ar
} else {
dstW = w
dstH = w / ar
}
val left = (w - dstW) / 2f
val top = (h - dstH) / 2f
viewport.set(left, top, left + dstW, top + dstH)
canvas.drawBitmap(bitmap, null, viewport, paint) canvas.drawBitmap(bitmap, null, viewport, paint)
} }
drawOsd(canvas, vm.state.value) drawOsd(canvas, vm.state.value)
} }
private fun drawIdle(canvas: Canvas, w: Float, h: Float) { private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
// status text is shown by the Compose overlay; keep the canvas black if (sx < 0 || sy < 0 || tempC == null) return
val p = sensorToScreen(sx, sy)
val cx = p[0]
val cy = p[1]
val dotR = 4.5f * density
// bigger ring + dot marker
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(cx, cy, dotR, markerPaint)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val pad = 6f * density
var tx = cx + 14f * density
var ty = cy + textPaint.textSize
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
canvas.drawText(text, tx, ty, textPaint)
} }
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
textPaint.color = Color.WHITE
val ox = viewport.left + 10f
val oy = viewport.top + textPaint.textSize + 8f
state.centerTempC?.let {
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos, state.maxTempC)
}
drawTempMarker(canvas, state.minPos, state.minTempC)
for (p in state.probes) {
drawTempMarker(canvas, p.x + p.y * 160, p.tempC, p.label)
}
drawColorBar(canvas, state)
}
/** Side color bar: palette gradient + min/max temperature labels. */
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) { private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex] val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val barW = 14f val barW = 20f * density
val barH = viewport.height() * 0.66f val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 8f val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint() val seg = Paint()
val n = 64 val n = 96
for (i in 0 until n) { for (i in 0 until n) {
// top = hot (palette 255), bottom = cold (palette 0)
val c = pal[255 - i * 255 / (n - 1)] val c = pal[255 - i * 255 / (n - 1)]
val sy = y0 + barH * i / n val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n val ey = y0 + barH * (i + 1) / n
@@ -152,30 +195,24 @@ 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 - textPaint.measureText(maxT) / 2, y0 - 4f, textPaint) canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint)
canvas.drawText(minT, x - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint) canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
} }
private fun sensorToScreen(pos: Int): FloatArray { private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
val x = pos % 160 textPaint.color = Color.WHITE
val y = pos / 160 val ox = viewport.left + 12f * density
val sx = viewport.left + (x / 160f) * viewport.width() val oy = viewport.top + textPaint.textSize + 10f * density
val sy = viewport.top + (y / 120f) * viewport.height() state.centerTempC?.let {
return floatArrayOf(sx, sy) canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
} }
if (state.maxTraceOn) {
private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) { drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
if (pos < 0 || tempC == null) return }
val p = sensorToScreen(pos) drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC) for (p in state.probes) {
val tw = textPaint.measureText(text) drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
val cx = p[0] }
val cy = p[1] drawColorBar(canvas, state)
var tx = cx + 8f
var ty = cy + textPaint.textSize + 6f
if (tx + tw + 4f > canvas.width) tx = cx - 8f - tw
if (ty > canvas.height) ty = cy - 6f
canvas.drawCircle(cx, cy, 4f, textPaint)
canvas.drawText(text, tx, ty, textPaint)
} }
} }
@@ -38,9 +38,16 @@ import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView import android.view.SurfaceView
/**
* Stable single-branch layout for the live view: the SurfaceView is created
* once and survives orientation changes. The control bar is an overlay
* (bottom row in portrait, right column in landscape).
*/
@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
var showPalette by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
vm.connect() vm.connect()
@@ -50,134 +57,162 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
} }
} }
Surface(color = MaterialTheme.colorScheme.background) { val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
Column(modifier = Modifier.fillMaxSize()) { android.content.res.Configuration.ORIENTATION_LANDSCAPE
Box(
modifier = Modifier Box(modifier = Modifier.fillMaxSize()) {
.weight(1f) AndroidSurface(vm)
.fillMaxSize(), Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
)
}
},
)
if (!state.connected) {
Text(
text = statusText(state),
modifier = Modifier.align(Alignment.Center).padding(16.dp),
)
}
if (isLandscape) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.align(Alignment.CenterEnd),
) { ) {
AndroidSurface(vm) Column(
// tap overlay: add / remove probe points modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
Box( horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier verticalArrangement = Arrangement.SpaceEvenly,
.fillMaxSize() ) {
.pointerInput(Unit) { IconButton(onClick = { vm.triggerFfc() }) {
detectTapGestures { offset -> Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
vm.tapImage( }
offset.x, offset.y, IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
size.width.toFloat(), size.height.toFloat(), Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
) }
}
},
)
if (!state.connected) {
Text( Text(
text = when (state.status) { text = if (state.maxTraceOn) "追踪·开" else "追踪",
"no_device" -> "未检测到热像仪,请插入MAG160C" style = MaterialTheme.typography.labelMedium,
"no_permission" -> "USB权限未授予" color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
"ddt_fail" -> "标定文件加载失败" else MaterialTheme.colorScheme.onSurfaceVariant,
else -> "连接中…" modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(8.dp),
}, )
modifier = Modifier.align(Alignment.Center).padding(16.dp), IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelSmall,
) )
} }
} }
ControlBar(state, vm) } else {
} Surface(
} color = MaterialTheme.colorScheme.surfaceVariant,
} modifier = Modifier.align(Alignment.BottomCenter),
) {
@Composable Row(
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { modifier = Modifier
var showPalette by remember { mutableStateOf(false) } .fillMaxWidth()
val context = LocalContext.current .padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
Surface(color = MaterialTheme.colorScheme.surfaceVariant) { ) {
Row( Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
modifier = Modifier IconButton(onClick = { vm.triggerFfc() }) {
.fillMaxWidth() Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
.padding(horizontal = 8.dp, vertical = 2.dp), }
horizontalArrangement = Arrangement.SpaceEvenly, }
verticalAlignment = Alignment.CenterVertically, Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
) { IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
IconButton(onClick = { vm.triggerFfc() }) { Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
Icon(painterResource(R.drawable.ic_ffc), contentDescription = "FFC 快门校正") }
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { vm.toggleMaxTrace() },
)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
}
}
} }
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
Icon(painterResource(R.drawable.ic_zoom), contentDescription = "数码变倍 x${state.zoom}")
}
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clickable { vm.toggleMaxTrace() }
.padding(6.dp),
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = state.centerTempC?.let { "%.1f℃".format(it) } ?: "--",
style = MaterialTheme.typography.titleLarge,
)
Text(
text = if (state.maxTempC != null && state.minTempC != null)
"%.1f / %.1f℃".format(state.maxTempC, state.minTempC)
else "-- / --℃",
style = MaterialTheme.typography.bodySmall,
)
}
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板")
}
IconButton(onClick = {
val ctx = context
vm.capturePhoto(ctx)
}) {
Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照")
}
IconButton(onClick = {
val ctx = context
vm.toggleRecording(ctx)
}) {
Icon(painterResource(R.drawable.ic_record), contentDescription = "录像")
}
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelMedium,
)
} }
} }
if (showPalette) { if (showPalette) {
AlertDialog( PaletteDialog(
onDismissRequest = { showPalette = false }, current = state.paletteIndex,
title = { Text("调色板") }, onSelect = { vm.setPalette(it); showPalette = false },
text = { onDismiss = { showPalette = false },
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(320.dp),
) {
items((0..11).toList()) { idx ->
Text(
text = Palettes.NAMES[idx],
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable {
vm.setPalette(idx)
showPalette = false
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
) )
} }
} }
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
"ddt_fail" -> "标定文件加载失败"
else -> "连接中…"
}
@Composable
private fun PaletteDialog(current: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("调色板") },
text = {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(320.dp),
) {
items((0..11).toList()) { idx ->
Text(
text = Palettes.NAMES[idx],
style = MaterialTheme.typography.bodyLarge,
color = if (idx == current) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable { onSelect(idx) }
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
}
/** Compose host for the SurfaceView renderer. */ /** Compose host for the SurfaceView renderer. */
@Composable @Composable
private fun AndroidSurface(vm: LiveViewModel) { private fun AndroidSurface(vm: LiveViewModel) {
@@ -231,44 +231,75 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/** /**
* Tap on the live image: add a probe point, or delete an existing one when * Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Coordinates are view pixels; converted via the same * tapping near it. Rotation-aware: portrait draws the frame rotated 90
* letterbox + zoom math the renderer uses. * deg CW (same mapping as LiveRenderer).
*/ */
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) { fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value val s = _state.value
if (!s.streaming) return if (!s.streaming) return
val ar = 4f / 3f val isPortrait = viewH > viewW
val viewRatio = viewW / viewH
val dstW: Float val dstW: Float
val dstH: Float val dstH: Float
if (viewRatio > ar) { val left: Float
dstH = viewH val top: Float
dstW = viewH * ar if (isPortrait) {
} else {
dstW = viewW dstW = viewW
dstH = viewW / ar dstH = viewW * 320f / 240f
left = 0f
top = (viewH - dstH) / 2f
} else {
val ar = 4f / 3f
val viewRatio = viewW / viewH
if (viewRatio > ar) {
dstH = viewH
dstW = viewH * ar
} else {
dstW = viewW
dstH = viewW / ar
}
left = (viewW - dstW) / 2f
top = (viewH - dstH) / 2f
} }
val left = (viewW - dstW) / 2f
val top = (viewH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return
val srcW = 160f / s.zoom val fx = (screenX - left) / dstW
val srcH = 120f / s.zoom val fy = (screenY - top) / dstH
val sx = (screenX - left) / dstW * srcW + (160 - srcW) / 2f val sx: Float
val sy = (screenY - top) / dstH * srcH + (120 - srcH) / 2f val sy: Float
val sensorX = sx.toInt().coerceIn(0, 159) if (isPortrait) {
val sensorY = sy.toInt().coerceIn(0, 119) // fx = 1 - sy/120, fy = sx/160 (inverse of the rotated draw mapping)
// near an existing probe? delete it instead of adding sy = (1f - fx) * 120f
sx = fy * 160f
} else {
sx = fx * 160f
sy = fy * 120f
}
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f val thr = dstW * 0.06f
val existing = s.probes.firstOrNull { p -> val existing = s.probes.firstOrNull { p ->
kotlin.math.abs(p.x + 0.5f - sx) * (dstW / srcW) < thr && val pxs: Float
kotlin.math.abs(p.y + 0.5f - sy) * (dstH / srcH) < thr val pys: Float
if (isPortrait) {
pxs = left + (1f - p.y / 120f) * dstW
pys = top + (p.x / 160f) * dstH
} else {
pxs = left + (p.x / 160f) * dstW
pys = top + (p.y / 120f) * dstH
}
val dx = screenX - pxs
val dy = screenY - pys
dx * dx + dy * dy < thr * thr
} }
if (existing != null) { if (existing != null) {
_state.value = _state.value.copy(probes = _state.value.probes - existing) _state.value = _state.value.copy(probes = _state.value.probes - existing)
return return
} }
val label = "Pt${s.probes.size + 1}" val label = "Pt${s.probes.size + 1}"
val p = ProbePoint(sensorX, sensorY, label, null) val p = ProbePoint(
sx.toInt().coerceIn(0, 159),
sy.toInt().coerceIn(0, 119),
label,
null,
)
_state.value = _state.value.copy(probes = _state.value.probes + p) _state.value = _state.value.copy(probes = _state.value.probes + p)
refreshTemps() refreshTemps()
} }
Binary file not shown.