From 6d1be708371c6a2d8829a1f6ea067e9f9a136d07 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:02:03 +0100 Subject: [PATCH] Use measurement system preference for location search --- .../java/de/mm20/launcher2/ui/ktx/Float.kt | 40 ----- .../search/common/SearchableItemVM.kt | 5 +- .../launcher/search/location/LocationItem.kt | 26 +++- .../widgets/weather/WeatherWidgetVM.kt | 2 +- .../locations/LocationsSettingsScreen.kt | 33 ++-- .../locations/LocationsSettingsScreenVM.kt | 28 +++- .../java/de/mm20/launcher2/ui/utils/Utils.kt | 141 ++++++++++++++++++ .../preferences/LauncherSettingsData.kt | 1 - .../search/LocationSearchSettings.kt | 15 +- 9 files changed, 202 insertions(+), 89 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/utils/Utils.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt index e5208f777..de338ca0a 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/ktx/Float.kt @@ -23,46 +23,6 @@ fun Float.toDp(): Dp { return (this / LocalDensity.current.density).dp } -fun Float.roundToString(): String = this.roundToInt().toString() - -fun Float.metersToLocalizedString(context: Context, imperialUnits: Boolean): String { - val decimalFormat = - DecimalFormat().apply { maximumFractionDigits = 1; minimumFractionDigits = 0 } - - val (value, unit) = if (imperialUnits) { - // yee haw - val asFeet = this * 3.28084f - val isYards = asFeet >= 3f - val isMiles = asFeet >= 5280f - val value = - if (isMiles) decimalFormat.format(asFeet / 5280f) - else if (isYards) (asFeet / 3f).roundToString() - else asFeet.roundToString() - - val unit = context.getString( - if (isMiles) R.string.unit_mile_symbol - else if (isYards) R.string.unit_yard_symbol - else R.string.unit_foot_symbol - ) - - value to unit - } else { - val isKm = this >= 1000f - val value = - if (isKm) decimalFormat.format(this / 1000f) - else this.roundToString() - - val unit = context.getString( - if (isKm) R.string.unit_kilometer_symbol - else R.string.unit_meter_symbol - ) - - value to unit - } - - return "$value $unit" -} - // https://stackoverflow.com/a/68651222 val Float.Companion.DegreesConverter get() = TwoWayConverter({ diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt index fbb2bef0e..ebdd9e652 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/common/SearchableItemVM.kt @@ -16,6 +16,7 @@ import de.mm20.launcher2.notifications.Notification import de.mm20.launcher2.notifications.NotificationRepository import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.MeasurementSystem import de.mm20.launcher2.preferences.search.ContactSearchSettings import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.search.AppShortcut @@ -236,8 +237,8 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent { permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts) } - val imperialUnits = locationSearchSettings.imperialUnits - .stateIn(viewModelScope, SharingStarted.Lazily, false) + val measurementSystem = locationSearchSettings.measurementSystem + .stateIn(viewModelScope, SharingStarted.Lazily, MeasurementSystem.Metric) val showMap = locationSearchSettings.showMap .stateIn(viewModelScope, SharingStarted.Lazily, false) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt index e86712744..8af46e374 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/location/LocationItem.kt @@ -90,6 +90,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import blend.Blend.harmonize import coil.compose.AsyncImage import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.preferences.MeasurementSystem import de.mm20.launcher2.search.Location import de.mm20.launcher2.search.isOpen import de.mm20.launcher2.search.location.Attribution @@ -110,7 +111,6 @@ import de.mm20.launcher2.ui.component.Toolbar import de.mm20.launcher2.ui.component.ToolbarAction import de.mm20.launcher2.ui.ktx.atTone import de.mm20.launcher2.ui.ktx.blendIntoViewScale -import de.mm20.launcher2.ui.ktx.metersToLocalizedString import de.mm20.launcher2.ui.ktx.toComposeColor import de.mm20.launcher2.ui.launcher.search.common.SearchableItemVM import de.mm20.launcher2.ui.launcher.search.listItemViewModel @@ -119,6 +119,7 @@ import de.mm20.launcher2.ui.locals.LocalDarkTheme import de.mm20.launcher2.ui.locals.LocalFavoritesEnabled import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.modifier.scale +import de.mm20.launcher2.ui.utils.formatDistance import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow import java.time.Duration @@ -161,7 +162,7 @@ fun LocationItem( val icon by viewModel.icon.collectAsStateWithLifecycle() val badge by viewModel.badge.collectAsStateWithLifecycle(null) - val imperialUnits by viewModel.imperialUnits.collectAsState() + val measurementSystem by viewModel.measurementSystem.collectAsState() val showMap by viewModel.showMap.collectAsState() @@ -201,8 +202,13 @@ fun LocationItem( this@AnimatedContent ) ) - val formattedDistance = - distance?.metersToLocalizedString(context, imperialUnits) + val formattedDistance = distance?.let { + formatDistance( + context, + it, + measurementSystem + ) + } val sublabel = listOf(location.category, formattedDistance) .fastFilterNotNull() .joinToString(" • ") @@ -313,9 +319,13 @@ fun LocationItem( ) { val category = location.category val acceptedPaymentMethods = location.acceptedPaymentMethods - val formattedDistance = distance?.metersToLocalizedString( - context, imperialUnits - ) + val formattedDistance = distance?.let { + formatDistance( + context, + it, + measurementSystem + ) + } if (category != null || formattedDistance != null) { Text( when { @@ -1314,4 +1324,4 @@ private fun departureInMinutes(context: Context, departure: Departure): String { timeLeft ) } -} \ No newline at end of file +} diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt index edc33ff1e..2b4caa751 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/weather/WeatherWidgetVM.kt @@ -132,7 +132,7 @@ class WeatherWidgetVM : ViewModel(), KoinComponent { } return@map ms } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MeasurementSystem.System) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MeasurementSystem.Metric) fun selectDay(index: Int) { selectedDayIndex = min(index, forecasts.lastIndex) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt index 1d593b6cd..1b9db1264 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreen.kt @@ -1,6 +1,10 @@ package de.mm20.launcher2.ui.settings.locations import android.app.PendingIntent +import android.content.Context +import android.icu.number.NumberFormatter +import android.icu.util.Measure +import android.icu.util.MeasureUnit import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme @@ -17,25 +21,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavKey import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.ktx.sendWithBackgroundPermission import de.mm20.launcher2.plugin.PluginState +import de.mm20.launcher2.preferences.MeasurementSystem import de.mm20.launcher2.preferences.search.LocationSearchSettings import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.preferences.GuardedPreference -import de.mm20.launcher2.ui.component.preferences.ListPreference import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceWithSwitch import de.mm20.launcher2.ui.component.preferences.SliderPreference import de.mm20.launcher2.ui.component.preferences.SwitchPreference import de.mm20.launcher2.ui.component.preferences.TextPreference -import de.mm20.launcher2.ui.ktx.metersToLocalizedString import de.mm20.launcher2.ui.locals.LocalBackStack import de.mm20.launcher2.ui.settings.osm.OsmSettingsRoute +import de.mm20.launcher2.ui.utils.formatDistance import kotlinx.serialization.Serializable @Serializable -data object LocationsSettingsRoute: NavKey +data object LocationsSettingsRoute : NavKey @Composable fun LocationsSettingsScreen() { @@ -45,7 +50,7 @@ fun LocationsSettingsScreen() { val context = LocalContext.current val osmLocations by viewModel.osmLocations.collectAsState() - val imperialUnits by viewModel.imperialUnits.collectAsState() + val measurementSystem by viewModel.measurementSystem.collectAsState() val radius by viewModel.radius.collectAsState() val showMap by viewModel.showMap.collectAsState() val themeMap by viewModel.themeMap.collectAsState() @@ -109,18 +114,6 @@ fun LocationsSettingsScreen() { } item { PreferenceCategory { - ListPreference( - title = stringResource(R.string.length_unit), - items = listOf( - stringResource(R.string.imperial) to true, - stringResource(R.string.metric) to false - ), - enabled = anyLocationProviderEnabled, - value = imperialUnits, - onValueChanged = { - viewModel.setImperialUnits(it) - } - ) SliderPreference( title = stringResource(R.string.preference_search_locations_radius), value = radius, @@ -136,12 +129,9 @@ fun LocationsSettingsScreen() { modifier = Modifier .width(64.dp) .padding(start = 16.dp), - text = it.toFloat() - .metersToLocalizedString(LocalContext.current, imperialUnits), + text = formatDistance(context, it.toFloat(), measurementSystem), style = MaterialTheme.typography.titleSmall ) - it.toFloat() - .metersToLocalizedString(LocalContext.current, imperialUnits) } ) } @@ -185,4 +175,5 @@ fun LocationsSettingsScreen() { } } } -} \ No newline at end of file +} + diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt index 2970b777f..175710b33 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locations/LocationsSettingsScreenVM.kt @@ -1,11 +1,16 @@ package de.mm20.launcher2.ui.settings.locations +import android.icu.util.LocaleData +import android.icu.util.ULocale import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.plugin.PluginType import de.mm20.launcher2.plugins.PluginService +import de.mm20.launcher2.preferences.MeasurementSystem import de.mm20.launcher2.preferences.search.LocationSearchSettings import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -27,12 +32,23 @@ class LocationsSettingsScreenVM : ViewModel(), KoinComponent { settings.setOsmLocations(osmLocations) } - val imperialUnits = settings.imperialUnits - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - fun setImperialUnits(imperialUnits: Boolean) { - settings.setImperialUnits(imperialUnits) - } + val measurementSystem = settings.measurementSystem + .map { ms -> + if (ms == MeasurementSystem.System) { + return@map if (isAtLeastApiLevel(28)) { + val systemMs = LocaleData.getMeasurementSystem(ULocale.getDefault()) + when(systemMs) { + LocaleData.MeasurementSystem.UK -> MeasurementSystem.UnitedKingdom + LocaleData.MeasurementSystem.US -> MeasurementSystem.UnitedStates + else -> MeasurementSystem.Metric + } + } else { + MeasurementSystem.Metric + } + } + return@map ms + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), MeasurementSystem.Metric) val radius = settings.searchRadius .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 1500) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/utils/Utils.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/utils/Utils.kt new file mode 100644 index 000000000..d34aaef60 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/utils/Utils.kt @@ -0,0 +1,141 @@ +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)}" + } +} \ No newline at end of file diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index 192a69e26..c0369280f 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -166,7 +166,6 @@ data class LauncherSettingsData internal constructor( @Deprecated("Use locationSearchProviders instead") val locationSearchEnabled: Boolean = false, val locationSearchProviders: Set = setOf("openstreetmaps"), - val locationSearchImperialUnits: Boolean = false, val locationSearchRadius: Int = 1500, val locationSearchHideUncategorized: Boolean = true, val locationSearchOverpassUrl: String? = null, diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt index 277afca7e..3a1d40038 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/search/LocationSearchSettings.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.preferences.search import de.mm20.launcher2.preferences.LauncherDataStore +import de.mm20.launcher2.preferences.MeasurementSystem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -15,7 +16,7 @@ class LocationSearchSettings internal constructor( hideUncategorized = it.locationSearchHideUncategorized, overpassUrl = it.locationSearchOverpassUrl, tileServer = it.locationSearchTileServer, - imperialUnits = it.locationSearchImperialUnits, + measurementSystem = it.localeMeasurementSystem, showMap = it.locationSearchShowMap, showPositionOnMap = it.locationSearchShowPositionOnMap, themeMap = it.locationSearchThemeMap, @@ -130,14 +131,8 @@ class LocationSearchSettings internal constructor( } } - val imperialUnits - get() = launcherDataStore.data.map { it.locationSearchImperialUnits } - - fun setImperialUnits(imperialUnits: Boolean) { - launcherDataStore.update { - it.copy(locationSearchImperialUnits = imperialUnits) - } - } + val measurementSystem + get() = launcherDataStore.data.map { it.localeMeasurementSystem } companion object { const val DefaultTileServerUrl = "https://tile.openstreetmap.org/\${z}/\${x}/\${y}.png" @@ -152,7 +147,7 @@ data class LocationSearchSettingsData( val hideUncategorized: Boolean = true, val overpassUrl: String? = null, val tileServer: String? = null, - val imperialUnits: Boolean = false, + val measurementSystem: MeasurementSystem = MeasurementSystem.System, val showMap: Boolean = false, val showPositionOnMap: Boolean = false, val themeMap: Boolean = true,