diff --git a/analysis/preview/preview_ui90_v8.png b/analysis/preview/preview_ui90_v8.png new file mode 100644 index 0000000..f230daa --- /dev/null +++ b/analysis/preview/preview_ui90_v8.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b884bcea0682a15b4f65d87ce7b27544e23a4483b975f9a9ce4322b529ea4a9 +size 804674 diff --git a/analysis/preview/preview_ui_v8.png b/analysis/preview/preview_ui_v8.png new file mode 100644 index 0000000..0b41157 --- /dev/null +++ b/analysis/preview/preview_ui_v8.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e8a406b8e33616c7019f4c1c6e20a6e4691aa5d2d42a7927c6be61fff1b00ef +size 796640 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 45b4148..25f57cf 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 @@ -59,7 +59,7 @@ fun AppRoot() { Box(modifier = Modifier.fillMaxSize()) { when (tab) { - 0 -> LiveScreen() + 0 -> LiveScreen(onOpenGallery = { tab = 1 }) 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { GalleryScreen() } 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 c51e5e8..3cd6491 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 @@ -1,24 +1,32 @@ package com.mag160c.thermal.ui.live +import android.view.SurfaceView +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size 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 +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -31,41 +39,47 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.viewmodel.compose.viewModel import com.mag160c.thermal.R import com.mag160c.thermal.core.Palettes -import android.view.SurfaceView +import com.mag160c.thermal.ui.DeviceOrientation +import com.mag160c.thermal.ui.UiInsets /** - * Stable single-branch live view: one SurfaceView for the whole screen, a - * control TOP bar (always visible) and the bottom navigation overlay. - * The activity is portrait-locked, so the whole composition (top bar / - * 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- - * 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. + * Stable single-branch live view, camera-app style: + * - control TOP bar (glued to the portrait top edge): FFC / zoom / max-temp + * trace / palette, each with a tiny label, pre-rotated by the grip angle; + * - camera shutter row above the bottom navigation: gallery shortcut, big + * photo shutter, record/stop; + * - the image region stays glued to the phone's portrait frame (matches the + * lens direction) and never moves; OSD text compensates the grip angle. */ @Composable -fun LiveScreen(vm: LiveViewModel = viewModel()) { +fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) { val state by vm.state.collectAsState() val context = LocalContext.current - val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState() + val phi by DeviceOrientation.deg.collectAsState() + val density = LocalDensity.current.density var showPalette by remember { mutableStateOf(false) } + var navPx by remember { mutableStateOf(UiInsets.navPx) } + var shutterPx by remember { mutableStateOf(0) } LaunchedEffect(Unit) { vm.connect() - vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx + vm.uiBottomPx = navPx + shutterPx while (true) { - kotlinx.coroutines.delay(500) - vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx + kotlinx.coroutines.delay(400) + navPx = UiInsets.navPx + vm.uiBottomPx = navPx + shutterPx vm.refreshTemps() } } @@ -101,7 +115,6 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - // cutout / status-bar safe area (punch-hole phones) .windowInsetsPadding( WindowInsets.safeDrawing .only(WindowInsetsSides.Top), @@ -111,49 +124,154 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { - IconButton(onClick = { vm.triggerFfc() }) { + Box( + modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable { vm.triggerFfc() }.padding(4.dp), + ) { Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正") + Text("FFC", style = MaterialTheme.typography.labelSmall) } } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, 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).graphicsLayer { rotationZ = -phi.toFloat() }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable { + vm.setZoom(vm.state.value.zoom % 4 + 1) + }.padding(4.dp), + ) { + Icon(painterResource(R.drawable.ic_zoom), "数码变倍") + Text("${state.zoom}×", style = MaterialTheme.typography.labelSmall) } } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, 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).graphicsLayer { rotationZ = -phi.toFloat() }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp), + ) { + val on = state.maxTraceOn + Icon( + painterResource(R.drawable.ic_target), "最高温追踪", + tint = if (on) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + if (on) "追踪·开" else "追踪", + style = MaterialTheme.typography.labelSmall, + color = if (on) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { - IconButton(onClick = { showPalette = true }) { + Box( + modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable { showPalette = true }.padding(4.dp), + ) { Icon(painterResource(R.drawable.ic_palette), "调色板") + Text( + Palettes.NAMES[state.paletteIndex], + style = MaterialTheme.typography.labelSmall, + ) } } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { - IconButton(onClick = { vm.capturePhoto(context) }) { - Icon(painterResource(R.drawable.ic_camera), "拍照") + } + } + + // ---- camera shutter row above the bottom navigation (live tab) ---- + val recording = state.status == "recording" + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = -(navPx / density).dp) + .onSizeChanged { + shutterPx = it.height + vm.uiBottomPx = navPx + shutterPx + } + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(52.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, + ) { + Box( + modifier = Modifier + .size(52.dp) + .background(Color(0x59FFFFFF), CircleShape) + .clickable { onOpenGallery() }, + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(R.drawable.ic_gallery), null, tint = Color.White) + } + Text( + "相册", color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, + ) { + Box( + modifier = Modifier + .size(74.dp) + .border(4.dp, Color.White, CircleShape) + .padding(6.dp) + .background(Color.White, CircleShape) + .clickable { vm.capturePhoto(context) }, + ) + Text( + "拍照", color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, + ) { + Box( + modifier = Modifier + .size(52.dp) + .border(3.dp, Color(0xFFFF5252), CircleShape) + .clickable { vm.toggleRecording(context) }, + contentAlignment = Alignment.Center, + ) { + if (recording) { + Box( + modifier = Modifier + .size(20.dp) + .background(Color(0xFFFF5252), RoundedCornerShape(4.dp)), + ) + } else { + Box( + modifier = Modifier + .size(24.dp) + .background(Color(0xFFFF5252), CircleShape), + ) } } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { - IconButton(onClick = { vm.toggleRecording(context) }) { - Icon(painterResource(R.drawable.ic_record), "录像") - } - } - Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) { - Text( - text = Palettes.NAMES[state.paletteIndex], - style = MaterialTheme.typography.labelMedium, - ) - } + Text( + if (recording) "停止" else "录像", color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) } } } diff --git a/android/app/src/main/res/drawable/ic_target.xml b/android/app/src/main/res/drawable/ic_target.xml new file mode 100644 index 0000000..fc915ee --- /dev/null +++ b/android/app/src/main/res/drawable/ic_target.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 5613b4e..1beac62 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:c94cca39eccb20a020f732dbfe4dcc3bd86684d50cf676216612536eec7c9a71 -size 11863017 +oid sha256:1724e546b307e0f5a99c92a5c5a4701feb9cf14bf4322b14dc4d05a4038910db +size 12232282 diff --git a/docs/android_app/HANDOFF_DEVELOPMENT.md b/docs/android_app/HANDOFF_DEVELOPMENT.md index 721a501..d1940f2 100644 --- a/docs/android_app/HANDOFF_DEVELOPMENT.md +++ b/docs/android_app/HANDOFF_DEVELOPMENT.md @@ -118,9 +118,10 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形 - **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。 - **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射: `fx=1-sy/120, fy=sx/160`)。 -- 顶栏=控制项(FFC/变倍/追踪·开/调色板/拍照/录像/调色板名),恒在顶部,`safeDrawing` - 顶部inset 适配挖孔屏。 -- 底栏=导航恒在底部。 +- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字 + 标签,`safeDrawing` 顶部inset 适配挖孔屏。 +- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 / + 录像-停止;快门区高度并入 `vm.uiBottomPx`(= 导航高+快门区高)。 - 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`;底导航高度经 `UiInsets.navPx`→`vm.uiBottomPx`,渲染器据此留白。 - 演示模式:无设备时用真实管线+DDT渲染合成帧(热块场景),demo温度用局部线性显示映射 (真机温度走管线数学;绝对温度标定待真机)。 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index a7e5d03..257f358 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -166,6 +166,19 @@ 正持显示,90/180/270 补偿几何不变(preview_fix_pose*_v7.png)。 - [x] 单元测试全过;APK 已更新。 +## 用户反馈修复 第八轮(2026-09-07,相机式按键布局) + +- [x] **顶栏精简为 4 个相机控制项**(左→右,带小字标签,随持机朝向补偿旋转): + FFC 校正 / 数码变倍(显示当前 1×/2×/4×,点击循环)/ 最高温追踪开关(追踪·开, + 高亮+新 ic_target 准星图标)/ 调色板(显示当前调色板名,点击弹出选择)。 +- [x] **底部新增相机快门区**(仅实时页,位于底导航上方,白/红色调): + 相册快捷入口(跳转媒体库页签)/ 大圆形快门=拍照 / 录像-停止(红圈,录像中 + 红圈内容变红色方块)。快门区高度并入渲染器底部 inset(vm.uiBottomPx = + 导航高+快门区高),测温点点击映射同步排除该区域。 +- [x] 底部导航(实时/相册/分析/设置)不变;快门区内容同样按持机朝向补偿旋转。 +- [x] 模拟器实测:0°/90° 姿态布局、旋转补偿、图像区域均正确 + (preview_ui_v8.png / preview_ui90_v8.png);单元测试全过;APK 已更新。 + ## 待办 - 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)