android: settings screen scrollable + wire dead rows (language/about) + instant orientation apply

This commit is contained in:
ZXCLI
2026-09-11 03:59:55 +08:00
parent f7bca894ba
commit 3704ee797b
8 changed files with 237 additions and 20 deletions
@@ -95,18 +95,26 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = navPx + shutterPx
// Seed the renderer with the persisted orientation settings once; from
// then on AppSettings publishes changes, so the settings tab applies
// immediately instead of being picked up by this loop (which only runs
// while the live tab is composed).
com.mag160c.thermal.ui.settings.ImageOrientationSettings.publishFrom(context)
while (true) {
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
// pick up orientation changes made on the settings tab
val s = com.mag160c.thermal.ui.settings.AppSettings(context)
vm.userRotateDeg = s.imageRotateDeg
vm.flipH = s.imageFlipH
vm.flipV = s.imageFlipV
vm.refreshTemps()
}
}
// Apply orientation changes the moment they are made (also from the settings tab)
val orientation by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state
.collectAsState()
LaunchedEffect(orientation) {
vm.userRotateDeg = orientation.rotateDeg
vm.flipH = orientation.flipH
vm.flipV = orientation.flipV
}
// The PIP overlay owns the camera: it releases on any of these exits —
// PIP switched off (overlay leaves composition), live screen left, or the
@@ -77,13 +77,17 @@ fun RemoteViewerScreen(
kotlinx.coroutines.delay(400)
navPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
// keep the remote image oriented like the live view
val s = com.mag160c.thermal.ui.settings.AppSettings(context)
vm.userRotateDeg = s.imageRotateDeg
vm.flipH = s.imageFlipH
vm.flipV = s.imageFlipV
}
}
// The remote image uses the same locked orientation + manual corrections as
// the live view, applied the moment they change.
val orientation by com.mag160c.thermal.ui.settings.ImageOrientationSettings.state
.collectAsState()
LaunchedEffect(orientation) {
vm.userRotateDeg = orientation.rotateDeg
vm.flipH = orientation.flipH
vm.flipV = orientation.flipV
}
LaunchedEffect(Unit) {
vm.disconnected.collect { reason -> onDisconnected(reason) }
}
@@ -1,6 +1,39 @@
package com.mag160c.thermal.ui.settings
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Process-wide observable copy of the image-orientation settings.
*
* The settings screen writes [AppSettings] and the live/remote renderers need the
* new values immediately. Before this existed, the renderers polled AppSettings
* every 400 ms from the live screen's loop — which only runs while the LIVE tab
* is composed, so a change made on the settings tab was applied only after
* switching back and hoping the polling had not been torn down. Publishing here
* makes the change take effect at once, wherever it was made.
*/
object ImageOrientationSettings {
data class State(
val rotateDeg: Int = 0,
val flipH: Boolean = false,
val flipV: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
fun publish(rotateDeg: Int, flipH: Boolean, flipV: Boolean) {
_state.value = State(rotateDeg, flipH, flipV)
}
/** Read persisted values and publish them (called once when settings load). */
fun publishFrom(context: Context) {
val s = AppSettings(context)
publish(s.imageRotateDeg, s.imageFlipH, s.imageFlipV)
}
}
/** App settings backed by SharedPreferences (app-private storage only). */
class AppSettings(context: Context) {
@@ -35,21 +68,37 @@ class AppSettings(context: Context) {
/**
* Manual image orientation corrections, mirroring the official app's
* "旋转USB画面" / "水平翻转" / "竖直翻转" settings.
* "旋转USB画面" / "水平翻转" / "竖直翻转" settings. The image itself stays
* LOCKED to the portrait frame (see ImageTransform).
*
* Every setter also publishes the new combination so the renderers pick it up
* immediately rather than on the next poll.
*/
var imageRotateDeg: Int
get() = sp.getInt("imageRotate", 0)
set(v) = sp.edit().putInt("imageRotate", ((v % 360) + 360) % 360).apply()
set(v) {
val n = ((v % 360) + 360) % 360
sp.edit().putInt("imageRotate", n).apply()
ImageOrientationSettings.publish(n, imageFlipH, imageFlipV)
}
var imageFlipH: Boolean
get() = sp.getBoolean("imageFlipH", false)
set(v) = sp.edit().putBoolean("imageFlipH", v).apply()
set(v) {
sp.edit().putBoolean("imageFlipH", v).apply()
ImageOrientationSettings.publish(imageRotateDeg, v, imageFlipV)
}
var imageFlipV: Boolean
get() = sp.getBoolean("imageFlipV", false)
set(v) = sp.edit().putBoolean("imageFlipV", v).apply()
set(v) {
sp.edit().putBoolean("imageFlipV", v).apply()
ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, v)
}
init {
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
// seed the observable with the persisted orientation settings
ImageOrientationSettings.publish(imageRotateDeg, imageFlipH, imageFlipV)
}
}
@@ -7,6 +7,8 @@ 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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -38,18 +40,30 @@ fun SettingsScreen(
var rotateDeg by remember { mutableStateOf(settings.imageRotateDeg) }
var flipH by remember { mutableStateOf(settings.imageFlipH) }
var flipV by remember { mutableStateOf(settings.imageFlipV) }
// local Compose copy of the language choice, so the row label refreshes
var language by remember { mutableStateOf(settings.language) }
// remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
// 11 settings rows overflow a phone screen; without this the lower
// ones were simply unreachable ("选项翻不动")
.verticalScroll(rememberScrollState()),
) {
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
SettingRow(
"默认发射率",
"%.2f".format(settings.defaultEmissivityPercent / 100f),
) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
SettingRow(
"语言",
if (language == "zh") "中文" else "跟随系统",
) { dialog = "language" }
// manual orientation corrections (the official app has the same three)
SettingRow("旋转USB画面", "$rotateDeg°") { dialog = "rotate" }
SettingRow("水平翻转", if (flipH) "已开启" else "已关闭") {
@@ -74,7 +88,7 @@ fun SettingsScreen(
}
}
SettingRow("远程预览客户端", "查找主机") { onOpenRemoteClient() }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = "about" }
}
when (dialog) {
@@ -157,7 +171,8 @@ fun SettingsScreen(
},
confirmButton = {},
)
"cloud" -> AlertDialog( onDismissRequest = { dialog = null },
"cloud" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("云同步") },
text = {
Text(
@@ -174,6 +189,58 @@ fun SettingsScreen(
}
},
)
"language" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("语言") },
text = {
Column {
// The UI is written in Chinese with hard-coded strings and the
// app ships no translations, so offering other languages here
// would be a lie. The choice is still recorded so the row is
// not a dead end and a future translation can honour it.
Text(
"当前版本界面仅提供中文,其它语言尚未翻译。此处选择会被记录," +
"后续版本接入翻译后生效。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
listOf("auto" to "跟随系统", "zh" to "中文").forEach { (code, label) ->
Text(
label,
color = if (language == code) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable {
language = code
settings.language = code
dialog = null
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
"about" -> AlertDialog(
onDismissRequest = { dialog = null },
title = { Text("关于") },
text = {
Column {
Text("MAG160C 统一热像版 1.0.0")
Text(
"热像仪:MAG160C160×12015fpsUSB VID 0x833C",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
},
confirmButton = {
TextButton(onClick = { dialog = null }) { Text("") }
},
)
else -> {}
}
@@ -0,0 +1,73 @@
package com.mag160c.thermal.ui.settings
import com.mag160c.thermal.ui.live.ImageTransform
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The orientation settings must reach the renderer even when they are changed on
* the settings tab (the live screen is not composed then). AppSettings publishes
* every change to [ImageOrientationSettings]; these tests pin that contract.
*/
class ImageOrientationSettingsTest {
private fun reset() {
ImageOrientationSettings.publish(0, false, false)
}
@Test
fun publishedValuesAreReadableImmediately() {
reset()
ImageOrientationSettings.publish(90, flipH = true, flipV = false)
val s = ImageOrientationSettings.state.value
assertEquals(90, s.rotateDeg)
assertTrue(s.flipH)
assertFalse(s.flipV)
}
@Test
fun publishedValuesFeedTheImageTransform() {
reset()
ImageOrientationSettings.publish(180, flipH = false, flipV = true)
val s = ImageOrientationSettings.state.value
val p = ImageTransform.params(s.rotateDeg, s.flipH, s.flipV)
// locked base 90 + 180 = 270, with only the vertical flip set
assertEquals(270, p.rotDeg)
assertFalse(p.flipH)
assertTrue(p.flipV)
}
@Test
fun defaultStateLeavesTheLockedOrientationAlone() {
reset()
val s = ImageOrientationSettings.state.value
assertEquals(0, s.rotateDeg)
assertFalse(s.flipH)
assertFalse(s.flipV)
assertEquals(ImageTransform.LOCKED_ROT_DEG, ImageTransform.params(s.rotateDeg, s.flipH, s.flipV).rotDeg)
}
/**
* The equivalent pair the user found on the device: (flipV, +90) and
* (flipH, +270) produce the same on-screen mapping. Both must be reachable
* through the published settings.
*/
@Test
fun theTwoEquivalentConfigurationsAreBothRepresentable() {
ImageOrientationSettings.publish(0, flipH = false, flipV = true)
val a = ImageOrientationSettings.state.value
val pa = ImageTransform.params(a.rotateDeg, a.flipH, a.flipV)
ImageOrientationSettings.publish(180, flipH = true, flipV = false)
val b = ImageOrientationSettings.state.value
val pb = ImageTransform.params(b.rotateDeg, b.flipH, b.flipV)
assertEquals(90, pa.rotDeg)
assertEquals(270, pb.rotDeg)
// see ImageTransformOrientationTest for the pixel-level equivalence proof
assertTrue(pa.flipV && !pa.flipH)
assertTrue(pb.flipH && !pb.flipV)
}
}
Binary file not shown.
@@ -64,6 +64,9 @@
| 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 |
| 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 |
| 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
| 33 | 设置页**从上往下滑动**,看能否滚到最底部("关于") | (无日志) | 页面可以滚动,**11 行设置项全部可达**(此前无滚动,下方几行点不到) |
| 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) |
| 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) |
## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+14 -1
View File
@@ -495,7 +495,20 @@
主机实现"后来者写 busy"accept 循环不再阻塞在 serve 上,第二客户端立即
收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。
另删除未使用的 `ACCESS_NETWORK_STATE` 权限。
- [x] 单测 44 → **62 项全绿**debug + release(R8) 双构建通过;APK 已更新
- [x] **设置页翻不动 + 死行**(用户报"设置页选项翻不动"):
① 设置页 `Column` **缺 `verticalScroll`**11 行内容一屏放不下,下方几行
完全够不到 → 已加滚动;
② "语言"行点了没反应:`when(dialog)` 里根本没有 `"language"` 分支,
点击后落进 `else -> {}`;且该项**没有任何代码读取、也没有 i18n 资源**
(全部界面为中文字面量、无 strings.xml)→ 补上对话框并**如实说明"当前仅中文,
选择会被记录待后续翻译"**,不再假装能切语言;
③ "关于"行同样设 `dialog = null` 属死行 → 补上说明对话框;
**设置变更即时生效**:原先实时/远程页每 400ms 轮询 `AppSettings`,而该轮询
只在实时页处于组合状态时运行(在设置页改动后要切回实时页才生效)→ 新增
`ImageOrientationSettings`(进程级 StateFlow),`AppSettings` 三个方向
setter 写入后立即 publish,实时/远程页 collect 后即时应用。
新增 `ImageOrientationSettingsTest`4 项);单测 62 → **66 项全绿**
- [x] 单测 62 → **66 项全绿**debug + release(R8) 双构建通过;APK 已更新
12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍
not-required、多余权限已消失。