Skip to content

Commit 64a0e95

Browse files
nichu42Copilot
andcommitted
feat: localize numeric formatting for temperature, pressure, wind, and coordinates
- Make TemperatureConverter, PressureConverter, and WindConverter locale-aware with convertToDouble() numeric chain and formatValue() display helpers. - Refactor SensorDisplayConverter to format only the final display value using Locale.getDefault() while keeping intermediate values numeric. - Update SensorValueColorResolver to use numeric conversion for thresholds. - Switch remaining UI coordinate/percentage formatting from Locale.US to Locale.getDefault(). - Keep canonical AQI/storage values locale-neutral for stable parsing. - Update ConverterTest to fix locale to US and restore after tests. - Add number-formatting guideline to AGENTS.md localization rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 88034e1 commit 64a0e95

11 files changed

Lines changed: 216 additions & 66 deletions

File tree

app/src/main/java/de/nichu42/boxviewer/ui/AddBoxConfirmScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ private fun BoxPreviewCard(
271271
Spacer(modifier = Modifier.height(4.dp))
272272
} else {
273273
box.currentLocation?.let { loc ->
274-
val latStr = String.format(java.util.Locale.US, "%.4f", loc.latitude)
275-
val lngStr = String.format(java.util.Locale.US, "%.4f", loc.longitude)
274+
val latStr = String.format(java.util.Locale.getDefault(), "%.4f", loc.latitude)
275+
val lngStr = String.format(java.util.Locale.getDefault(), "%.4f", loc.longitude)
276276
Text(
277277
text = "Coords: $latStr, $lngStr",
278278
style = MaterialTheme.typography.bodySmall,

app/src/main/java/de/nichu42/boxviewer/ui/BoxDetailScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,7 @@ fun SensorCard(sensor: Sensor, boxId: String, viewModel: SenseBoxViewModel) {
800800
}
801801
if (nowCastResult.isAvailable) {
802802
val scoreText = if (nowCastResult.value != null) {
803-
String.format(Locale.US, "%.0f", nowCastResult.value)
803+
String.format(Locale.getDefault(), "%.0f", nowCastResult.value)
804804
} else {
805805
""
806806
}

app/src/main/java/de/nichu42/boxviewer/ui/DiscoveryScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -936,8 +936,8 @@ fun DiscoveredBoxCard(
936936
}
937937
} else {
938938
box.currentLocation?.let { loc ->
939-
val latStr = String.format(Locale.US, "%.4f", loc.latitude)
940-
val lngStr = String.format(Locale.US, "%.4f", loc.longitude)
939+
val latStr = String.format(Locale.getDefault(), "%.4f", loc.latitude)
940+
val lngStr = String.format(Locale.getDefault(), "%.4f", loc.longitude)
941941
Row(
942942
modifier = Modifier.padding(top = 2.dp),
943943
verticalAlignment = Alignment.CenterVertically

app/src/main/java/de/nichu42/boxviewer/ui/SenseBoxViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -905,7 +905,7 @@ class SenseBoxViewModel(application: Application) : AndroidViewModel(application
905905
fun getAddressFromLocation(lat: Double, lng: Double, onResult: (String) -> Unit) {
906906
viewModelScope.launch {
907907
val ctx = getApplication<Application>()
908-
var label = ctx.getString(R.string.location_coordinates_label, "%.4f".format(java.util.Locale.US, lat), "%.4f".format(java.util.Locale.US, lng))
908+
var label = ctx.getString(R.string.location_coordinates_label, "%.4f".format(java.util.Locale.getDefault(), lat), "%.4f".format(java.util.Locale.getDefault(), lng))
909909
try {
910910
val fallbackLabel = reverseGeocodeWithFallback(lat, lng)
911911
if (fallbackLabel.isNotBlank()) {
@@ -985,7 +985,7 @@ class SenseBoxViewModel(application: Application) : AndroidViewModel(application
985985
}
986986

987987
if (label.isBlank()) {
988-
label = getApplication<Application>().getString(R.string.location_lat_lon_label, "%.3f".format(java.util.Locale.US, lat), "%.3f".format(java.util.Locale.US, lng))
988+
label = getApplication<Application>().getString(R.string.location_lat_lon_label, "%.3f".format(java.util.Locale.getDefault(), lat), "%.3f".format(java.util.Locale.getDefault(), lng))
989989
}
990990
boxAddressCache[boxId] = label
991991
onResult(label)
@@ -1008,7 +1008,7 @@ class SenseBoxViewModel(application: Application) : AndroidViewModel(application
10081008
}
10091009

10101010
if (label.isBlank()) {
1011-
label = getApplication<Application>().getString(R.string.location_lat_lon_label, "%.3f".format(java.util.Locale.US, lat), "%.3f".format(java.util.Locale.US, lng))
1011+
label = getApplication<Application>().getString(R.string.location_lat_lon_label, "%.3f".format(java.util.Locale.getDefault(), lat), "%.3f".format(java.util.Locale.getDefault(), lng))
10121012
}
10131013
boxFullAddressCache[boxId] = label
10141014
onResult(label)

app/src/main/java/de/nichu42/boxviewer/ui/WidgetConfigScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ fun WidgetConfigScreen(
11351135
)
11361136
Spacer(modifier = Modifier.width(4.dp))
11371137
Text(
1138-
text = String.format(java.util.Locale.US, "%.0f%%", textScale * 100),
1138+
text = String.format(java.util.Locale.getDefault(), "%.0f%%", textScale * 100),
11391139
style = MaterialTheme.typography.bodyMedium,
11401140
fontWeight = FontWeight.Bold,
11411141
color = MaterialTheme.colorScheme.primary

app/src/main/java/de/nichu42/boxviewer/util/PressureConverter.kt

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,32 @@ import java.text.DecimalFormatSymbols
55
import java.util.Locale
66

77
object PressureConverter {
8-
private val separatorFormatter = DecimalFormat("#,##0.##", DecimalFormatSymbols(Locale.US))
9-
private val rawFormatter = DecimalFormat("0.##", DecimalFormatSymbols(Locale.US))
108

11-
fun convertValue(valueStr: String?, fromUnit: String?, targetUnit: String, formatPressure: Boolean): String {
12-
if (valueStr == null) return "--"
13-
9+
private fun separatorFormatter(locale: Locale) =
10+
DecimalFormat("#,##0.##", DecimalFormatSymbols(locale))
11+
12+
private fun rawFormatter(locale: Locale) =
13+
DecimalFormat("0.##", DecimalFormatSymbols(locale))
14+
15+
/**
16+
* Converts [valueStr] from [fromUnit] to [targetUnit] and returns the numeric
17+
* result, or null if the source is not a pressure unit or cannot be parsed.
18+
*/
19+
fun convertToDouble(valueStr: String?, fromUnit: String?, targetUnit: String): Double? {
20+
if (valueStr == null) return null
21+
1422
val fromUnitClean = fromUnit?.trim() ?: "hPa"
1523
val isPa = fromUnitClean.equals("Pa", ignoreCase = true)
1624
val isHpa = fromUnitClean.equals("hPa", ignoreCase = true)
1725
val isMbar = fromUnitClean.equals("mbar", ignoreCase = true) || fromUnitClean.equals("mb", ignoreCase = true)
1826
val isInHg = fromUnitClean.equals("inHg", ignoreCase = true)
1927
val isMmHg = fromUnitClean.equals("mmHg", ignoreCase = true) || fromUnitClean.equals("torr", ignoreCase = true)
20-
21-
if (!isPa && !isHpa && !isMbar && !isInHg && !isMmHg) return valueStr
28+
29+
if (!isPa && !isHpa && !isMbar && !isInHg && !isMmHg) return null
2230

2331
return try {
2432
val value = valueStr.toDouble()
25-
33+
2634
// First normalize to hPa
2735
val hpaValue = when {
2836
isPa -> value / 100.0
@@ -32,19 +40,41 @@ object PressureConverter {
3240
}
3341

3442
// Then convert to target unit
35-
val targetValue = when (targetUnit) {
43+
when (targetUnit) {
3644
"Pa" -> hpaValue * 100.0
3745
"inHg" -> hpaValue / 33.8639
3846
"mmHg" -> hpaValue / 1.33322
3947
else -> hpaValue // hPa or mbar (1 hPa = 1 mbar)
4048
}
41-
42-
if (formatPressure) {
43-
separatorFormatter.format(targetValue)
44-
} else {
45-
rawFormatter.format(targetValue)
46-
}
4749
} catch (e: Exception) {
50+
null
51+
}
52+
}
53+
54+
/**
55+
* Formats a pressure numeric value for the given locale.
56+
*/
57+
fun formatValue(value: Double, formatPressure: Boolean, locale: Locale = Locale.getDefault()): String {
58+
return if (formatPressure) {
59+
separatorFormatter(locale).format(value)
60+
} else {
61+
rawFormatter(locale).format(value)
62+
}
63+
}
64+
65+
fun convertValue(
66+
valueStr: String?,
67+
fromUnit: String?,
68+
targetUnit: String,
69+
formatPressure: Boolean,
70+
locale: Locale = Locale.getDefault()
71+
): String {
72+
if (valueStr == null) return "--"
73+
74+
val value = convertToDouble(valueStr, fromUnit, targetUnit)
75+
return if (value != null) {
76+
formatValue(value, formatPressure, locale)
77+
} else {
4878
valueStr
4979
}
5080
}

app/src/main/java/de/nichu42/boxviewer/util/SensorDisplayConverter.kt

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ object SensorDisplayConverter {
1414
val pressUnit: String?
1515
)
1616

17+
/**
18+
* Runs the numeric conversion chain and formats the final display value using
19+
* the current app locale. Intermediate values stay numeric so locale formatting
20+
* never breaks parsing between conversion steps.
21+
*/
1722
fun convert(
1823
rawValue: String?,
1924
sourceUnit: String?,
@@ -22,13 +27,65 @@ object SensorDisplayConverter {
2227
windUnit: String,
2328
formatPressure: Boolean
2429
): ConversionResult {
25-
val tempVal = TemperatureConverter.convertValue(rawValue, sourceUnit, temperatureUnit)
26-
val tempUnit = TemperatureConverter.convertUnit(sourceUnit, temperatureUnit)
27-
val pressVal = PressureConverter.convertValue(tempVal, tempUnit, pressureUnit, formatPressure)
28-
val pressUnit = PressureConverter.convertUnit(tempUnit, pressureUnit)
29-
val windVal = WindConverter.convertValue(pressVal, pressUnit, windUnit)
30-
val windUnitResult = WindConverter.convertUnit(pressUnit, windUnit)
30+
return convert(rawValue, sourceUnit, temperatureUnit, pressureUnit, windUnit, formatPressure, java.util.Locale.getDefault())
31+
}
32+
33+
fun convert(
34+
rawValue: String?,
35+
sourceUnit: String?,
36+
temperatureUnit: String,
37+
pressureUnit: String,
38+
windUnit: String,
39+
formatPressure: Boolean,
40+
locale: java.util.Locale
41+
): ConversionResult {
42+
// Temperature step
43+
val tempDouble = TemperatureConverter.convertToDouble(rawValue, sourceUnit, temperatureUnit)
44+
val tempUnit = if (tempDouble != null) {
45+
TemperatureConverter.convertUnit(sourceUnit, temperatureUnit)
46+
} else {
47+
sourceUnit
48+
}
49+
50+
// Pressure step (passes through non-pressure values unchanged)
51+
val pressDouble = PressureConverter.convertToDouble(
52+
tempDouble?.toString() ?: rawValue,
53+
tempUnit,
54+
pressureUnit
55+
)
56+
val pressUnit = if (pressDouble != null) {
57+
PressureConverter.convertUnit(tempUnit, pressureUnit)
58+
} else {
59+
tempUnit
60+
}
61+
62+
// Wind step (passes through non-wind values unchanged)
63+
val windDouble = WindConverter.convertToDouble(
64+
pressDouble?.toString() ?: tempDouble?.toString() ?: rawValue,
65+
pressUnit,
66+
windUnit
67+
)
68+
val windUnitResult = if (windDouble != null) {
69+
WindConverter.convertUnit(pressUnit, windUnit)
70+
} else {
71+
pressUnit
72+
}
73+
3174
val displayUnit = UnitUnifier.unifyUnit(windUnitResult)
32-
return ConversionResult(value = windVal, unit = displayUnit, tempUnit = tempUnit, pressUnit = pressUnit)
75+
76+
// Format the final numeric value with the converter that actually handled it.
77+
val displayValue = when {
78+
windDouble != null -> WindConverter.formatValue(windDouble, locale)
79+
pressDouble != null -> PressureConverter.formatValue(pressDouble, formatPressure, locale)
80+
tempDouble != null -> TemperatureConverter.formatValue(tempDouble, locale)
81+
else -> rawValue
82+
}
83+
84+
return ConversionResult(
85+
value = displayValue,
86+
unit = displayUnit,
87+
tempUnit = tempUnit,
88+
pressUnit = pressUnit
89+
)
3390
}
3491
}

app/src/main/java/de/nichu42/boxviewer/util/SensorValueColorResolver.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ object SensorValueColorResolver {
2525

2626
val value = valueString?.let { raw ->
2727
when {
28-
lower.contains("temp") -> TemperatureConverter.convertValue(raw, unit, "°C").toDoubleOrNull()
28+
lower.contains("temp") -> TemperatureConverter.convertToDouble(raw, unit, "°C")
2929
lower.contains("druck") || lower.contains("press") ->
30-
PressureConverter.convertValue(raw, unit, "hPa", formatPressure = false).toDoubleOrNull()
30+
PressureConverter.convertToDouble(raw, unit, "hPa")
3131
else -> raw.toDoubleOrNull()
3232
}
3333
}

app/src/main/java/de/nichu42/boxviewer/util/TemperatureConverter.kt

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ package de.nichu42.boxviewer.util
33
import java.util.Locale
44

55
object TemperatureConverter {
6-
fun convertValue(valueStr: String?, fromUnit: String?, targetUnit: String): String {
7-
if (valueStr == null) return "--"
8-
6+
7+
/**
8+
* Converts [valueStr] from [fromUnit] to [targetUnit] and returns the numeric
9+
* result, or null if the source is not a temperature unit or cannot be parsed.
10+
*/
11+
fun convertToDouble(valueStr: String?, fromUnit: String?, targetUnit: String): Double? {
12+
if (valueStr == null) return null
13+
914
val fromUnitClean = fromUnit?.trim() ?: "°C"
1015
val isFromF = fromUnitClean.equals("°F", ignoreCase = true) || fromUnitClean.equals("F", ignoreCase = true)
1116
val isFromK = fromUnitClean.equals("K", ignoreCase = true) || fromUnitClean.equals("Kelvin", ignoreCase = true)
1217
val isFromC = fromUnitClean.equals("°C", ignoreCase = true) || fromUnitClean.equals("C", ignoreCase = true)
13-
14-
// If the source unit is explicitly a non-temperature unit, bypass conversion and return valueStr unmodified
18+
1519
val hasOriginalUnit = fromUnit != null && fromUnit.trim().isNotEmpty()
1620
val isTempUnit = isFromF || isFromK || isFromC
1721
if (hasOriginalUnit && !isTempUnit) {
18-
return valueStr
22+
return null
1923
}
2024

2125
return try {
@@ -27,32 +31,48 @@ object TemperatureConverter {
2731
}
2832

2933
when (targetUnit) {
30-
"°F" -> {
31-
val fahrenheit = celsius * 1.8 + 32.0
32-
String.format(Locale.US, "%.1f", fahrenheit)
33-
}
34-
"K" -> {
35-
val kelvin = celsius + 273.15
36-
String.format(Locale.US, "%.1f", kelvin)
37-
}
38-
else -> {
39-
String.format(Locale.US, "%.1f", celsius)
40-
}
34+
"°F" -> celsius * 1.8 + 32.0
35+
"K" -> celsius + 273.15
36+
else -> celsius
4137
}
4238
} catch (e: Exception) {
39+
null
40+
}
41+
}
42+
43+
/**
44+
* Formats a temperature numeric value for the given locale.
45+
*/
46+
fun formatValue(value: Double, locale: Locale = Locale.getDefault()): String {
47+
return String.format(locale, "%.1f", value)
48+
}
49+
50+
fun convertValue(
51+
valueStr: String?,
52+
fromUnit: String?,
53+
targetUnit: String,
54+
locale: Locale = Locale.getDefault()
55+
): String {
56+
if (valueStr == null) return "--"
57+
58+
val value = convertToDouble(valueStr, fromUnit, targetUnit)
59+
return if (value != null) {
60+
formatValue(value, locale)
61+
} else {
62+
// Non-temperature unit or parse error: return the original value unchanged.
4363
valueStr
4464
}
4565
}
4666

4767
fun convertUnit(originalUnit: String?, targetUnit: String): String {
4868
val clean = originalUnit?.trim() ?: ""
49-
val isTemp = clean.equals("°C", ignoreCase = true) ||
50-
clean.equals("°F", ignoreCase = true) ||
51-
clean.equals("K", ignoreCase = true) ||
52-
clean.equals("Kelvin", ignoreCase = true) ||
53-
clean.equals("C", ignoreCase = true) ||
54-
clean.equals("F", ignoreCase = true)
55-
69+
val isTemp = clean.equals("°C", ignoreCase = true) ||
70+
clean.equals("°F", ignoreCase = true) ||
71+
clean.equals("K", ignoreCase = true) ||
72+
clean.equals("Kelvin", ignoreCase = true) ||
73+
clean.equals("C", ignoreCase = true) ||
74+
clean.equals("F", ignoreCase = true)
75+
5676
if (isTemp) {
5777
return targetUnit
5878
}

0 commit comments

Comments
 (0)