diff --git a/analysis/preview/preview_landscape.png b/analysis/preview/preview_landscape.png index a2c5b0c..531bcab 100644 --- a/analysis/preview/preview_landscape.png +++ b/analysis/preview/preview_landscape.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b1d18224c92a8461eb22bb188664ce7ca10ea2e8ce6caa72b05c1db3efe53b89 -size 95875 +oid sha256:9907dd1e8e9390434163ebd7bef5ad67a6f475afb9342cf93b61a10ce1d66ce1 +size 807556 diff --git a/analysis/preview/preview_live.png b/analysis/preview/preview_live.png index d0894c3..531bcab 100644 --- a/analysis/preview/preview_live.png +++ b/analysis/preview/preview_live.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a82a35ee11d76d1d32c40b56b7e16f5a2da7176f41783575122e37b379f0fe39 -size 565102 +oid sha256:9907dd1e8e9390434163ebd7bef5ad67a6f475afb9342cf93b61a10ce1d66ce1 +size 807556 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index edd6afc..571cb4e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ 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 a8905b2..9be98f5 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 @@ -1,17 +1,18 @@ 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 import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -37,56 +38,81 @@ private val TABS = listOf( 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 fun AppRoot() { var tab by rememberSaveable { mutableStateOf(0) } val isLandscape = 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) { 0 -> LiveScreen() - 1 -> GalleryScreen() - 2 -> GalleryScreen() - else -> SettingsScreen() + 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + GalleryScreen() + } + 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + GalleryScreen() + } + else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) { + SettingsScreen() + } } - } - if (isLandscape) { - Row(modifier = Modifier.fillMaxSize()) { - NavigationRail { - TABS.forEachIndexed { i, t -> - NavigationRailItem( - selected = tab == i, - onClick = { tab = i }, - icon = { Icon(painterResource(t.icon), null) }, - label = { Text(t.label) }, - ) + // ---- overlay navigation ---- + if (isLandscape) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.align(Alignment.CenterStart), + ) { + 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, + ) + } + } } } - Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() } - } - } else { - Scaffold(bottomBar = { - NavigationBar { - TABS.forEachIndexed { i, t -> - NavigationBarItem( - selected = tab == i, - onClick = { tab = i }, - icon = { Icon(painterResource(t.icon), null) }, - label = { Text(t.label) }, - ) + } else { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.align(Alignment.BottomCenter), + ) { + 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) }, + ) + } } } - }) { 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) - } -} 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 e6d170c..7293568 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 @@ -9,9 +9,13 @@ import android.view.SurfaceHolder import android.view.SurfaceView /** - * Software canvas renderer for the live IR stream. Mirrors the official - * apps' drawImage: letterbox the 320x240 frame into the 4:3 area, apply - * digital zoom, and overlay the temperature OSD. + * Software canvas renderer for the live IR stream. + * + * 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( private val surfaceView: SurfaceView, @@ -19,15 +23,23 @@ class LiveRenderer( ) : SurfaceHolder.Callback, Runnable { private var thread: Thread? = null 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 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 textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE typeface = Typeface.SANS_SERIF - textSize = 14f - setShadowLayer(2f, 0f, 0f, Color.BLACK) + textSize = 15f * density + 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 var isPortraitView = true fun attach() { 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) { val w = canvas.width.toFloat() val h = canvas.height.toFloat() canvas.drawColor(Color.BLACK) + isPortraitView = h > w + val frame = vm.latestFrame - if (frame == null) { - drawIdle(canvas, w, h) - return - } - bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240) + if (frame == null) return - // letterbox 4:3 - 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) - - val zoom = vm.state.value.zoom - val srcRect = if (zoom > 1) { - val cw = 320 / zoom - val ch = 240 / zoom - android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2) - } else null - - if (srcRect != null) { - canvas.drawBitmap(bitmap, srcRect, viewport, paint) + // build displayed bitmap (rotated in portrait) + if (rotationNeeded) { + bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240) + bitmap.getPixels(px, 0, 320, 0, 0, 320, 240) + val rpx = IntArray(240 * 320) + for (y in 0 until 240) { + for (x in 0 until 320) { + // 90 deg CW: dst(x',y') = src(y, 239-x) -> with sizes: dst(240x320) + // dst index: x' = 239 - src_y, y' = src_x + val srcIdx = y * 320 + x + val dstX = 239 - y + val dstY = x + rpx[dstY * 240 + dstX] = px[srcIdx] + } + } + rotated.setPixels(rpx, 0, 240, 0, 0, 240, 320) + // fit 3:4 (240x320) into the portrait view + val dstW = w + val dstH = w * 320f / 240f + val left = 0f + val top = (h - dstH) / 2f + viewport.set(left, top, left + dstW, top + dstH) + canvas.drawBitmap(rotated, null, viewport, paint) } 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) } drawOsd(canvas, vm.state.value) } - private fun drawIdle(canvas: Canvas, w: Float, h: Float) { - // status text is shown by the Compose overlay; keep the canvas black + private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) { + 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) { if (state.maxTempC == null || state.minTempC == null) return val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex] - val barW = 14f - val barH = viewport.height() * 0.66f - val x = viewport.right - barW - 8f + val barW = 20f * density + val barH = viewport.height() * 0.8f + val x = viewport.right - barW - 12f * density val y0 = viewport.top + (viewport.height() - barH) / 2f val seg = Paint() - val n = 64 + val n = 96 for (i in 0 until n) { - // top = hot (palette 255), bottom = cold (palette 0) val c = pal[255 - i * 255 / (n - 1)] val sy = y0 + barH * i / n val ey = y0 + barH * (i + 1) / n @@ -152,30 +195,24 @@ class LiveRenderer( textPaint.color = Color.WHITE val maxT = "%.1f".format(state.maxTempC) val minT = "%.1f".format(state.minTempC) - canvas.drawText(maxT, x - textPaint.measureText(maxT) / 2, y0 - 4f, textPaint) - canvas.drawText(minT, x - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint) + 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) } - private fun sensorToScreen(pos: Int): FloatArray { - val x = pos % 160 - val y = pos / 160 - val sx = viewport.left + (x / 160f) * viewport.width() - val sy = viewport.top + (y / 120f) * viewport.height() - return floatArrayOf(sx, sy) - } - - private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) { - if (pos < 0 || tempC == null) return - val p = sensorToScreen(pos) - val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC) - val tw = textPaint.measureText(text) - val cx = p[0] - val cy = p[1] - 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) + private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) { + textPaint.color = Color.WHITE + val ox = viewport.left + 12f * density + val oy = viewport.top + textPaint.textSize + 10f * density + state.centerTempC?.let { + canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint) + } + if (state.maxTraceOn) { + drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高") + } + drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null) + for (p in state.probes) { + drawTempMarker(canvas, p.x, p.y, p.tempC, p.label) + } + drawColorBar(canvas, state) } } 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 2e217be..ac42f98 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 @@ -38,9 +38,16 @@ import com.mag160c.thermal.R import com.mag160c.thermal.core.Palettes 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 fun LiveScreen(vm: LiveViewModel = viewModel()) { val state by vm.state.collectAsState() + val context = LocalContext.current + var showPalette by remember { mutableStateOf(false) } LaunchedEffect(Unit) { vm.connect() @@ -50,134 +57,162 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { } } - Surface(color = MaterialTheme.colorScheme.background) { - Column(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxSize(), + val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation == + android.content.res.Configuration.ORIENTATION_LANDSCAPE + + Box(modifier = Modifier.fillMaxSize()) { + AndroidSurface(vm) + 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) - // tap overlay: add / remove probe points - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures { offset -> - vm.tapImage( - offset.x, offset.y, - size.width.toFloat(), size.height.toFloat(), - ) - } - }, - ) - if (!state.connected) { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = { vm.triggerFfc() }) { + Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正") + } + IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) { + Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}") + } Text( - text = when (state.status) { - "no_device" -> "未检测到热像仪,请插入MAG160C" - "no_permission" -> "USB权限未授予" - "ddt_fail" -> "标定文件加载失败" - else -> "连接中…" - }, - modifier = Modifier.align(Alignment.Center).padding(16.dp), + 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(8.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) - } - } -} - -@Composable -private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { - var showPalette by remember { mutableStateOf(false) } - val context = LocalContext.current - - Surface(color = MaterialTheme.colorScheme.surfaceVariant) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = { vm.triggerFfc() }) { - Icon(painterResource(R.drawable.ic_ffc), contentDescription = "FFC 快门校正") + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { + IconButton(onClick = { vm.triggerFfc() }) { + Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正") + } + } + Box(modifier = Modifier.weight(1f), 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) { + 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) { - AlertDialog( - onDismissRequest = { showPalette = false }, - 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, - modifier = Modifier - .clickable { - vm.setPalette(idx) - showPalette = false - } - .padding(14.dp), - ) - } - } - }, - confirmButton = {}, + PaletteDialog( + current = state.paletteIndex, + onSelect = { vm.setPalette(it); showPalette = false }, + onDismiss = { showPalette = false }, ) } } +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. */ @Composable private fun AndroidSurface(vm: LiveViewModel) { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index ca7fe1d..88528a2 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -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 - * tapping near it. Coordinates are view pixels; converted via the same - * letterbox + zoom math the renderer uses. + * tapping near it. Rotation-aware: portrait draws the frame rotated 90 + * deg CW (same mapping as LiveRenderer). */ fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) { val s = _state.value if (!s.streaming) return - val ar = 4f / 3f - val viewRatio = viewW / viewH + val isPortrait = viewH > viewW val dstW: Float val dstH: Float - if (viewRatio > ar) { - dstH = viewH - dstW = viewH * ar - } else { + val left: Float + val top: Float + if (isPortrait) { 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 - val srcW = 160f / s.zoom - val srcH = 120f / s.zoom - val sx = (screenX - left) / dstW * srcW + (160 - srcW) / 2f - val sy = (screenY - top) / dstH * srcH + (120 - srcH) / 2f - val sensorX = sx.toInt().coerceIn(0, 159) - val sensorY = sy.toInt().coerceIn(0, 119) - // near an existing probe? delete it instead of adding + val fx = (screenX - left) / dstW + val fy = (screenY - top) / dstH + val sx: Float + val sy: Float + if (isPortrait) { + // fx = 1 - sy/120, fy = sx/160 (inverse of the rotated draw mapping) + 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 existing = s.probes.firstOrNull { p -> - kotlin.math.abs(p.x + 0.5f - sx) * (dstW / srcW) < thr && - kotlin.math.abs(p.y + 0.5f - sy) * (dstH / srcH) < thr + val pxs: Float + 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) { _state.value = _state.value.copy(probes = _state.value.probes - existing) return } 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) refreshTemps() } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index b81e9b0..d23e567 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:55eaf897888720eaf486c5b6c476e2b9ce888e9b3c641ca30667721400e9d976 -size 11820522 +oid sha256:08ef06842a8a7a9a9d626ca1b578de6ae3f76ebeb585253216da7f2c71429a41 +size 11829966