android: camera-style controls - 4-item top bar (ffc/zoom/trace/palette), shutter row (gallery/photo/record) above nav
This commit is contained in:
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.
@@ -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温度用局部线性显示映射
|
||||
(真机温度走管线数学;绝对温度标定待真机)。
|
||||
|
||||
@@ -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保存端到端)
|
||||
|
||||
Reference in New Issue
Block a user