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 b9d85bc..cbf44ad 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 @@ -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 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt index 18e8286..5eb396c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt index 8b06c02..d4e08e8 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt @@ -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 + + 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) } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index 8f5c410..5198150 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -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( + "热像仪:MAG160C(160×120,15fps,USB VID 0x833C)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton(onClick = { dialog = null }) { Text("好") } + }, + ) else -> {} } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/ui/settings/ImageOrientationSettingsTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/ui/settings/ImageOrientationSettingsTest.kt new file mode 100644 index 0000000..947b64c --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/ui/settings/ImageOrientationSettingsTest.kt @@ -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) + } +} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index ad4f841..ed39a93 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:73e2c981cd55d216d3dcdb8c955b4ef607117d6da1a671e6ee9e03b7d46cb0d1 +oid sha256:1c5d27923fa62cc4b49df87796fdce196827bd42a059e538e9efd9840fcf8130 size 12663188 diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index fe96218..02f5936 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -64,6 +64,9 @@ | 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 | | 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 | | 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 | +| 33 | 设置页**从上往下滑动**,看能否滚到最底部("关于") | (无日志) | 页面可以滚动,**11 行设置项全部可达**(此前无滚动,下方几行点不到) | +| 34 | 设置页依次点每一行,确认都有反应 | 调色板/发射率/报警温度/语言/旋转USB画面/云同步/关于 → 弹对话框;水平翻转/竖直翻转 → 文案在"已开启/已关闭"间切换;远程预览服务端 → 切换或弹"先连接热像仪";远程预览客户端 → 进入主机列表 | **没有点了没反应的行**(此前"语言"和"关于"是死行) | +| 35 | 在**设置页**改"竖直翻转"或"旋转USB画面",然后切回实时页 | (无日志) | 画面**立即**按新设置显示(设置变更即时下发;此前靠实时页 400ms 轮询,仅在实时页处于打开状态时才生效) | ## 相机(PIP)失败时的表现(设计如此,不算 bug) PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index c13e0be..bec1462 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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、多余权限已消失。