android: settings screen scrollable + wire dead rows (language/about) + instant orientation apply
This commit is contained in:
@@ -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(
|
||||
"热像仪: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 -> {}
|
||||
}
|
||||
|
||||
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user