android: camera-style controls - 4-item top bar (ffc/zoom/trace/palette), shutter row (gallery/photo/record) above nav

This commit is contained in:
ZXCLI
2026-09-07 19:22:49 +08:00
parent fabea4c664
commit 7437ce5e85
8 changed files with 198 additions and 52 deletions
Binary file not shown.
Binary file not shown.
@@ -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()
}
@@ -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,
)
}
}
}
@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,1.5 L12,5.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M12,18.5 L12,22.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M1.5,12 L5.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M18.5,12 L22.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M10.9,12a1.1,1.1 0 1,0 2.2,0a1.1,1.1 0 1,0 -2.2,0z" android:fillColor="#FF000000" />
</vector>
Binary file not shown.
+4 -3
View File
@@ -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温度用局部线性显示映射
(真机温度走管线数学;绝对温度标定待真机)。
+13
View File
@@ -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] **底部新增相机快门区**(仅实时页,位于底导航上方,白/红色调):
相册快捷入口(跳转媒体库页签)/ 大圆形快门=拍照 / 录像-停止(红圈,录像中
红圈内容变红色方块)。快门区高度并入渲染器底部 insetvm.uiBottomPx =
导航高+快门区高),测温点点击映射同步排除该区域。
- [x] 底部导航(实时/相册/分析/设置)不变;快门区内容同样按持机朝向补偿旋转。
- [x] 模拟器实测:0°/90° 姿态布局、旋转补偿、图像区域均正确
preview_ui_v8.png / preview_ui90_v8.png);单元测试全过;APK 已更新。
## 待办
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)