fix: derive phone-UI units from OS locale, not radio display config (#6101)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 20:36:55 -05:00
committed by GitHub
parent d815d6d363
commit 4d07bc6641
9 changed files with 79 additions and 66 deletions

View File

@@ -143,7 +143,6 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@@ -372,7 +371,6 @@ fun MapView(
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {

View File

@@ -27,9 +27,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.LocalConfig
@Suppress("LongParameterList")
@KoinViewModel
@@ -58,10 +56,5 @@ class MapViewModel(
mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val config
get() = localConfig.value
val applicationId = buildConfigProvider.applicationId
}

View File

@@ -35,25 +35,6 @@ actual fun currentLocaleQualifier(): String {
actual fun getSystemMeasurementSystem(): MeasurementSystem {
val locale = Locale.getDefault()
// Android 14+ (API 34) introduced user-settable locale preferences.
if (Build.VERSION.SDK_INT >= 34) {
try {
val localePrefsClass = Class.forName("androidx.core.text.util.LocalePreferences")
val getMeasurementSystemMethod =
localePrefsClass.getMethod("getMeasurementSystem", Locale::class.java, Boolean::class.javaPrimitiveType)
val result = getMeasurementSystemMethod.invoke(null, locale, true) as String
return when (result) {
"us",
"uk",
-> MeasurementSystem.IMPERIAL
else -> MeasurementSystem.METRIC
}
} catch (@Suppress("TooGenericExceptionCaught") ignored: Exception) {
// Fallback
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) {
LocaleData.MeasurementSystem.SI -> MeasurementSystem.METRIC

View File

@@ -49,12 +49,20 @@ object MetricFormatter {
fun rssi(value: Int): String = "$value dBm"
fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String =
"${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s"
fun windSpeed(metersPerSecond: Float, isImperial: Boolean, decimalPlaces: Int = 1): String {
val value = if (isImperial) metersPerSecond * MPH_PER_MPS else metersPerSecond
val unit = if (isImperial) "mph" else "m/s"
return "${NumberFormatter.format(value, decimalPlaces)} $unit"
}
fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String =
"${NumberFormatter.format(millimeters, decimalPlaces)} mm"
fun rainfall(millimeters: Float, isImperial: Boolean, decimalPlaces: Int = 1): String {
val value = if (isImperial) millimeters / MM_PER_INCH else millimeters
val unit = if (isImperial) "in" else "mm"
return "${NumberFormatter.format(value, decimalPlaces)} $unit"
}
}
private const val FAHRENHEIT_SCALE = 1.8f
private const val FAHRENHEIT_OFFSET = 32
private const val MPH_PER_MPS = 2.23694f
private const val MM_PER_INCH = 25.4f

View File

@@ -123,21 +123,31 @@ class MetricFormatterTest {
@Test
fun windSpeed() {
assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f))
assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f, isImperial = false))
}
@Test
fun windSpeedZero() {
assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f))
assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f, isImperial = false))
}
@Test
fun windSpeedImperial() {
assertEquals("27.6 mph", MetricFormatter.windSpeed(12.34f, isImperial = true))
}
@Test
fun rainfall() {
assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f))
assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f, isImperial = false))
}
@Test
fun rainfallZero() {
assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f))
assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f, isImperial = false))
}
@Test
fun rainfallImperial() {
assertEquals("0.10 in", MetricFormatter.rainfall(2.54f, isImperial = true, decimalPlaces = 2))
}
}

View File

@@ -32,6 +32,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -66,11 +67,12 @@ open class BaseMapViewModel(
val myNodeInfo = nodeRepository.myNodeInfo
/** Device display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. */
val displayUnits: StateFlow<DisplayUnits> =
radioConfigRepository.localConfigFlow
.map { it.display?.units ?: DisplayUnits.METRIC }
.stateInWhileSubscribed(initialValue = DisplayUnits.METRIC)
/**
* OS locale display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. StateFlow
* kept for the existing collectAsState call sites; value is a one-time snapshot at construction and does not react
* to a mid-session locale change (ViewModel survives config changes).
*/
val displayUnits: StateFlow<DisplayUnits> = MutableStateFlow(DistanceUnit.getFromLocale()).asStateFlow()
val ourNodeInfo = nodeRepository.ourNodeInfo

View File

@@ -31,6 +31,7 @@ import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
@@ -48,7 +49,7 @@ import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
@@ -182,8 +183,7 @@ constructor(
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val displayUnits = DistanceUnit.getFromLocale()
val metricsState =
MetricsState(
@@ -193,9 +193,7 @@ constructor(
deviceLinks = deviceLinks,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
isFahrenheit = displayUnits == DisplayUnits.IMPERIAL,
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
localStats = logs.telemetry.filter { it.local_stats != null },

View File

@@ -28,11 +28,14 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.getSystemMeasurementSystem
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.NodeListDensity
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.DistanceUnit
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.DeviceHardwareRepository
@@ -127,13 +130,17 @@ class NodeListViewModel(
excludeMqtt = excludeMqtt,
)
}
// OS locale rarely changes mid-session; snapshot once instead of per filter/sort emission.
private val distanceUnits = DistanceUnit.getFromLocale().value
private val tempInFahrenheit = getSystemMeasurementSystem() == MeasurementSystem.IMPERIAL
val nodesUiState: StateFlow<NodesUiState> =
combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile ->
combine(nodeSortOption, nodeFilter) { sort, nodeFilter ->
NodesUiState(
sort = sort,
filter = nodeFilter,
distanceUnits = profile.config?.display?.units?.value ?: 0,
tempInFahrenheit = profile.module_config?.telemetry?.environment_display_fahrenheit ?: false,
distanceUnits = distanceUnits,
tempInFahrenheit = tempInFahrenheit,
)
}
.stateInWhileSubscribed(initialValue = NodesUiState())

View File

@@ -123,6 +123,8 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isImperial =
state.displayUnits == org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.IMPERIAL,
isSelected = telemetry.time.toDouble() == selectedX,
onClick = { onCardClick(telemetry.time.toDouble()) },
)
@@ -368,21 +370,21 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
}
@Composable
private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) {
val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN()
val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN()
val hasLull = envMetrics.wind_lull != null && !envMetrics.wind_lull!!.isNaN()
if (hasSpeed || hasGust || hasLull) {
Column(modifier = Modifier.fillMaxWidth()) {
if (hasSpeed) WindSpeedRow(envMetrics)
if (hasGust || hasLull) WindGustLullRow(envMetrics, hasGust, hasLull)
if (hasSpeed) WindSpeedRow(envMetrics, isImperial)
if (hasGust || hasLull) WindGustLullRow(envMetrics, isImperial, hasGust, hasLull)
}
}
}
@Composable
private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.WIND_SPEED.color)
@@ -390,9 +392,9 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val dirText =
if (envMetrics.wind_direction != null) {
formatString(
"%s %.1f m/s (%s %d°)",
"%s %s (%s %d°)",
stringResource(Res.string.wind_speed),
envMetrics.wind_speed!!,
MetricFormatter.windSpeed(envMetrics.wind_speed!!, isImperial),
stringResource(Res.string.wind_direction),
envMetrics.wind_direction!!,
)
@@ -400,7 +402,7 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
formatString(
"%s %s",
stringResource(Res.string.wind_speed),
MetricFormatter.windSpeed(envMetrics.wind_speed!!),
MetricFormatter.windSpeed(envMetrics.wind_speed!!, isImperial),
)
}
Text(
@@ -413,18 +415,29 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) {
private fun WindGustLullRow(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
isImperial: Boolean,
hasGust: Boolean,
hasLull: Boolean,
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasGust) {
Text(
text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}",
text =
"${stringResource(Res.string.wind_gust)} ${
MetricFormatter.windSpeed(envMetrics.wind_gust!!, isImperial)
}",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
if (hasLull) {
Text(
text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}",
text =
"${stringResource(Res.string.wind_lull)} ${
MetricFormatter.windSpeed(envMetrics.wind_lull!!, isImperial)
}",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -433,7 +446,7 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics,
}
@Composable
private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) {
val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN()
val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN()
@@ -444,7 +457,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
text =
"${stringResource(
Res.string.rainfall_1h,
)} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}",
)} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!, isImperial)}",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -454,7 +467,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
text =
"${stringResource(
Res.string.rainfall_24h,
)} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}",
)} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!, isImperial)}",
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -500,11 +513,12 @@ private fun OneWireTemperatureDisplay(
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isImperial: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit)
EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit, isImperial)
}
}
@@ -512,6 +526,7 @@ private fun EnvironmentMetricsCard(
private fun EnvironmentMetricsContent(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isImperial: Boolean,
timeTextOverride: String? = null,
) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
@@ -539,8 +554,8 @@ private fun EnvironmentMetricsContent(
VoltageCurrentDisplay(envMetrics)
RadiationDisplay(envMetrics)
WindDisplay(envMetrics)
RainfallDisplay(envMetrics)
WindDisplay(envMetrics, isImperial)
RainfallDisplay(envMetrics, isImperial)
OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
}
}
@@ -576,6 +591,7 @@ fun PreviewEnvironmentMetricsContent() {
EnvironmentMetricsContent(
telemetry = fakeTelemetry,
environmentDisplayFahrenheit = false,
isImperial = false,
timeTextOverride = "2023-11-14 22:13",
)
}