Properly format numbers in weather widget

This commit is contained in:
MM20
2025-11-30 12:20:13 +01:00
parent ad01735bcf
commit c3b24f2221
3 changed files with 339 additions and 181 deletions

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.format.DateFormat
import android.text.format.DateUtils
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@@ -81,6 +80,10 @@ import de.mm20.launcher2.ui.component.Tooltip
import de.mm20.launcher2.ui.component.weather.AnimatedWeatherIcon
import de.mm20.launcher2.ui.component.weather.WeatherIcon
import de.mm20.launcher2.ui.theme.transparency.transparency
import de.mm20.launcher2.ui.utils.formatPercent
import de.mm20.launcher2.ui.utils.formatPrecipitation
import de.mm20.launcher2.ui.utils.formatSpeed
import de.mm20.launcher2.ui.utils.formatTemperature
import de.mm20.launcher2.weather.DailyForecast
import de.mm20.launcher2.weather.Forecast
import de.mm20.launcher2.widgets.WeatherWidget
@@ -88,7 +91,6 @@ import kotlinx.coroutines.flow.map
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.roundToInt
@Composable
fun WeatherWidget(widget: WeatherWidget) {
@@ -337,10 +339,11 @@ fun CurrentWeather(
) {
Text(
modifier = Modifier.weight(1f),
text = convertTemperature(
useFahrenheit = useFahrenheit,
temp = forecast.temperature
).toString() + "°",
text = formatTemperature(
context,
forecast.temperature.toFloat(),
measurementSystem
),
style = MaterialTheme.typography.headlineMedium,
)
Text(
@@ -379,7 +382,7 @@ fun CurrentWeather(
)
Spacer(modifier = Modifier.padding(3.dp))
Text(
text = "${forecast.humidity!!.roundToInt()} %",
text = formatPercent(forecast.humidity!!.toFloat()),
style = MaterialTheme.typography.bodySmall,
)
}
@@ -415,7 +418,11 @@ fun CurrentWeather(
Spacer(modifier = Modifier.padding(3.dp))
Text(
text = if (forecast.windSpeed != null) {
formatWindSpeed(useMph, forecast)
formatSpeed(
context,
forecast.windSpeed!!.toFloat(),
measurementSystem
)
} else {
windDirectionAsWord(forecast.windDirection!!)
},
@@ -439,7 +446,15 @@ fun CurrentWeather(
)
Spacer(modifier = Modifier.padding(3.dp))
Text(
text = formatPrecipitation(useInches, forecast),
text = if (forecast.precipProbability != null) {
formatPercent(forecast.precipProbability!!.toFloat())
} else {
formatPrecipitation(
context,
forecast.precipitation?.toFloat() ?: 0f,
measurementSystem
)
},
style = MaterialTheme.typography.bodySmall,
)
}
@@ -523,7 +538,11 @@ fun WeatherTimeSelector(
Text(
modifier = Modifier
.alpha(alpha),
text = "${convertTemperature(useFahrenheit, fc.temperature)}°",
text = formatTemperature(
context,
fc.temperature.toFloat(),
measurementSystem
),
softWrap = false,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -550,7 +569,7 @@ fun WeatherDaySelector(
measurementSystem: MeasurementSystem
) {
val dateFormat = SimpleDateFormat("EEE")
val useFahrenheit = measurementSystem == MeasurementSystem.UnitedStates
val context = LocalContext.current
val listState = rememberLazyListState()
LazyRow(
@@ -592,8 +611,18 @@ fun WeatherDaySelector(
Text(
modifier = Modifier.padding(start = 8.dp),
text = "${dateFormat.format(day.timestamp)} " +
"${convertTemperature(useFahrenheit, day.minTemp)}° / " +
"${convertTemperature(useFahrenheit, day.maxTemp)}°",
"${
formatTemperature(
context,
day.minTemp.toFloat(),
measurementSystem
)
} / " +
formatTemperature(
context,
day.maxTemp.toFloat(),
measurementSystem
),
softWrap = false,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -619,36 +648,13 @@ fun WeatherDaySelector(
private fun formatTime(context: Context, timestamp: Long, timeFormat: TimeFormat): String {
val skeleton = if (timeFormat == TimeFormat.TwelveHour) "h:mm a" else "H:mm"
val dateFormat = SimpleDateFormat(DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton), Locale.getDefault())
val dateFormat = SimpleDateFormat(
DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton),
Locale.getDefault()
)
return dateFormat.format(timestamp)
}
private fun convertTemperature(useFahrenheit: Boolean, temp: Double): Int {
return (if (useFahrenheit) temp * 9.0 / 5.0 - 459.67 else temp + -273.15).roundToInt()
}
@Composable
private fun formatWindSpeed(imperialUnits: Boolean, forecast: Forecast): String {
if (forecast.windSpeed == null) return ""
val formatter = DecimalFormat("0.#")
val speedValue = formatter.format(forecast.windSpeed!! * if (imperialUnits) 2.2369 else 1.0)
val speedUnit =
stringResource(id = if (imperialUnits) R.string.unit_mile_per_hour_symbol else R.string.unit_meter_per_second_symbol)
return "$speedValue $speedUnit"
}
@Composable
private fun formatPrecipitation(useInches: Boolean, forecast: Forecast): String {
if (forecast.precipProbability != null) {
return "${forecast.precipProbability} %"
}
val formatter = if (useInches) DecimalFormat("#.##") else DecimalFormat("#.#")
val precipUnit =
if (useInches) stringResource(id = R.string.unit_inch_symbol) else stringResource(id = R.string.unit_millimeter_symbol)
return "${formatter.format(forecast.precipitation)} $precipUnit"
}
@Composable
private fun windDirectionAsWord(direction: Double): String {
return stringResource(

View File

@@ -0,0 +1,293 @@
package de.mm20.launcher2.ui.utils
import android.content.Context
import android.icu.number.NumberFormatter
import android.icu.number.Precision
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.preferences.MeasurementSystem
import de.mm20.launcher2.ui.R
import java.text.NumberFormat
import java.util.Locale
internal fun formatDistance(
context: Context,
meters: Float,
measurementSystem: MeasurementSystem,
): String {
when (measurementSystem) {
MeasurementSystem.UnitedStates -> {
val feet = meters * 3.28084f
if (feet >= 2640) {
return formatMiles(context, feet / 5280f)
}
return formatFeet(context, feet)
}
MeasurementSystem.UnitedKingdom -> {
val yards = meters * 1.09361f
if (yards >= 880) {
return formatMiles(context, yards / 1760f)
}
return formatYards(context, yards)
}
else -> {
if (meters >= 1000) {
return formatKilometers(context, meters / 1000f)
}
return formatMeters(context, meters)
}
}
}
private fun formatMeters(
context: Context,
meters: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(meters, MeasureUnit.METER)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(meters)
return "$formatted ${context.getString(R.string.unit_meter_symbol)}"
}
}
private fun formatKilometers(
context: Context,
kilometers: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(1))
.format(Measure(kilometers, MeasureUnit.KILOMETER)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 1
}.format(kilometers)
return "$formatted ${context.getString(R.string.unit_kilometer_symbol)}"
}
}
private fun formatFeet(
context: Context,
feet: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(feet, MeasureUnit.FOOT)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(feet)
return "$formatted ${context.getString(R.string.unit_foot_symbol)}"
}
}
private fun formatYards(
context: Context,
yards: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(yards, MeasureUnit.YARD)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(yards)
return "$formatted ${context.getString(R.string.unit_yard_symbol)}"
}
}
private fun formatMiles(
context: Context,
miles: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(1))
.format(Measure(miles, MeasureUnit.MILE)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 1
}.format(miles)
return "$formatted ${context.getString(R.string.unit_mile_symbol)}"
}
}
internal fun formatSpeed(
context: Context,
metersPerSecond: Float,
measurementSystem: MeasurementSystem,
): String {
if (measurementSystem == MeasurementSystem.UnitedStates) {
return formatMpH(context, metersPerSecond * 2.2369f)
} else {
return formatKmH(context, metersPerSecond * 3.6f)
}
}
private fun formatKmH(
context: Context,
kmH: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(kmH, MeasureUnit.KILOMETER_PER_HOUR)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(kmH)
return "$formatted ${context.getString(R.string.unit_kilometer_per_hour_symbol)}"
}
}
private fun formatMpH(
context: Context,
mph: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(mph, MeasureUnit.MILE_PER_HOUR)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(mph)
return "$formatted ${context.getString(R.string.unit_mile_per_hour_symbol)}"
}
}
internal fun formatTemperature(
context: Context,
kelvins: Float,
measurementSystem: MeasurementSystem,
): String {
if (measurementSystem == MeasurementSystem.UnitedStates) {
return formatFahrenheit(context, kelvins * 9f / 5f - 459.67f)
} else {
return formatCelsius(context, kelvins + -273.15f)
}
}
private fun formatCelsius(
context: Context,
celsius: Float,
): String {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(celsius)
return "$formatted°"
}
private fun formatFahrenheit(
context: Context,
fahrenheit: Float,
): String {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(fahrenheit)
return "$formatted°"
}
internal fun formatPercent(
percent: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(percent, MeasureUnit.PERCENT)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(percent)
return "$formatted %"
}
}
internal fun formatPrecipitation(
context: Context,
millimeters: Float,
measurementSystem: MeasurementSystem,
): String {
if (measurementSystem == MeasurementSystem.UnitedStates) {
return formatInches(context, millimeters / 25.4f)
} else {
return formatMillimeters(context, millimeters)
}
}
private fun formatMillimeters(
context: Context,
millimeters: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(millimeters, MeasureUnit.MILLIMETER)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(millimeters)
return "$formatted ${context.getString(R.string.unit_millimeter_symbol)}"
}
}
private fun formatInches(
context: Context,
inches: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(1))
.format(Measure(inches, MeasureUnit.INCH)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 1
}.format(inches)
return "$formatted ${context.getString(R.string.unit_inch_symbol)}"
}
}

View File

@@ -1,141 +0,0 @@
package de.mm20.launcher2.ui.utils
import android.content.Context
import android.icu.number.NumberFormatter
import android.icu.number.Precision
import android.icu.util.Measure
import android.icu.util.MeasureUnit
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.preferences.MeasurementSystem
import de.mm20.launcher2.ui.R
import java.text.NumberFormat
import java.util.Locale
internal fun formatDistance(
context: Context,
meters: Float,
measurementSystem: MeasurementSystem,
): String {
when (measurementSystem) {
MeasurementSystem.UnitedStates -> {
val feet = meters * 3.28084f
if (feet >= 2640) {
return formatMiles(context, feet / 5280f)
}
return formatFeet(context, feet)
}
MeasurementSystem.UnitedKingdom -> {
val yards = meters * 1.09361f
if (yards >= 880) {
return formatMiles(context, yards / 1760f)
}
return formatYards(context, yards)
}
else -> {
if (meters >= 1000) {
return formatKilometers(context, meters / 1000f)
}
return formatMeters(context, meters)
}
}
}
private fun formatMeters(
context: Context,
meters: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(meters, MeasureUnit.METER)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(meters)
return "$formatted ${context.getString(R.string.unit_meter_symbol)}"
}
}
private fun formatKilometers(
context: Context,
kilometers: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(1))
.format(Measure(kilometers, MeasureUnit.KILOMETER)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 1
}.format(kilometers)
return "$formatted ${context.getString(R.string.unit_kilometer_symbol)}"
}
}
private fun formatFeet(
context: Context,
feet: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(feet, MeasureUnit.FOOT)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(feet)
return "$formatted ${context.getString(R.string.unit_foot_symbol)}"
}
}
private fun formatYards(
context: Context,
yards: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(0))
.format(Measure(yards, MeasureUnit.YARD)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 0
}.format(yards)
return "$formatted ${context.getString(R.string.unit_yard_symbol)}"
}
}
private fun formatMiles(
context: Context,
miles: Float,
): String {
if (isAtLeastApiLevel(30)) {
return NumberFormatter
.withLocale(
Locale.getDefault()
).unitWidth(NumberFormatter.UnitWidth.NARROW)
.precision(Precision.maxFraction(1))
.format(Measure(miles, MeasureUnit.MILE)).toString()
} else {
val formatted = NumberFormat.getInstance().apply {
maximumFractionDigits = 1
}.format(miles)
return "$formatted ${context.getString(R.string.unit_mile_symbol)}"
}
}