mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 12:26:23 -04:00
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:
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user