From 2a267cdd2df0c346b64c29c7c00de2d8d42ad32c Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Thu, 22 Oct 2026 07:40:32 -0600
Subject: [PATCH 1/5] feat: add Units API for distance and elevation conversion
and formatting
---
README.md | 41 +
demo/src/main/AndroidManifest.xml | 3 +
.../utils/demo/DistanceDemoActivity.java | 25 +-
.../maps/android/utils/demo/MainActivity.kt | 1 +
.../android/utils/demo/UnitsDemoActivity.kt | 287 +++++++
.../main/res/layout/activity_units_demo.xml | 33 +
demo/src/main/res/values/strings.xml | 12 +-
.../maps/android/utils/demo/UnitsDemoTest.kt | 40 +
library/api/library.api | 178 ++++
.../java/com/google/maps/android/Units.kt | 764 ++++++++++++++++++
library/src/main/res/values/strings.xml | 55 ++
.../java/com/google/maps/android/UnitsTest.kt | 364 +++++++++
llm-integration-prompt.md | 6 +
13 files changed, 1791 insertions(+), 18 deletions(-)
create mode 100644 demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
create mode 100644 demo/src/main/res/layout/activity_units_demo.xml
create mode 100644 demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
create mode 100644 library/src/main/java/com/google/maps/android/Units.kt
create mode 100644 library/src/main/res/values/strings.xml
create mode 100644 library/src/test/java/com/google/maps/android/UnitsTest.kt
diff --git a/README.md b/README.md
index 4508fef6d..55ad6e5f3 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ upgrading from 4.x, see the [migration guide](MIGRATION.md).
interoperability with Maps API web services
- **Spherical geometry** — for example: computeDistance, computeHeading,
computeArea
+- **Units & logical distance/elevation formatting** — type-safe `Meters` value class, framework-agnostic `UnitSystem` / `UnitsConverter` selection (`MetricUnitsConverter` and `ImperialUnitsConverter`) defaulting to the device's locale, and order-of-magnitude distance formatting
- **Street View metadata** — checks if a Street View panorama exists at a given location
- **Reactive Kotlin Extensions & Builders** — coroutine suspensions (`awaitMap()`), reactive `Flow` observers (`mapClickEvents()`), and option builder DSLs (`addMarker { ... }`) consolidated directly into `com.google.maps.android.*`.
@@ -164,6 +165,46 @@ Full guides for using the utilities are published in
- Polyline encoding and decoding [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/PolyUtil.kt), [encoding sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/PolySimplifyDemoActivity.java), [decoding sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/PolyDecodeDemoActivity.java)
- Spherical geometry [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/SphericalUtil.kt), [compute distance sample](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java)
+- Units & distance/elevation formatting [source](https://github.com/googlemaps/android-maps-utils/blob/main/library/src/main/java/com/google/maps/android/Units.kt), [sample code](https://github.com/googlemaps/android-maps-utils/blob/main/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt)
+
+
+
+
+ Units & Logical Distance/Elevation Formatting
+
+### Units & Logical Distance/Elevation Formatting
+
+The `Units` API (`com.google.maps.android`) provides a type-safe `Meters` value class, framework-agnostic `UnitSystem` resolution (defaulting to Imperial for `US`, `LR`, `MM`, and `GB` locales, and Metric elsewhere), and logical significant-digit formatting for distances and elevations:
+
+- **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers (`450 m`, `528 ft`, `14,440 ft`).
+- **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`0.3 mi`, `1.0 km`, `2.5 mi`).
+- **Large distances** (`>= 10` km or `>= 10` mi) round to whole numbers (`10 km`, `12 mi`).
+
+```kotlin
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.km
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+
+// Default converter for the device's current Locale (no Android Context required)
+val converter = getUnitsConverter()
+
+// Or explicitly choose a unit system / country code / Locale
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+val metric = getUnitsConverter("FR")
+
+// Format distances and elevations
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
+val summit = 4401.2.meters.toElevationString(imperial) // "14,440 ft"
+```
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
index 90301be60..c76c1898e 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demo/src/main/AndroidManifest.xml
@@ -151,6 +151,9 @@
+
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
index 7b4f22cea..190e66381 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/DistanceDemoActivity.java
@@ -27,6 +27,8 @@
import com.google.android.gms.maps.model.Polyline;
import com.google.android.gms.maps.model.PolylineOptions;
import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.Units;
+import com.google.maps.android.UnitsConverter;
import java.util.Arrays;
public class DistanceDemoActivity extends BaseDemoActivity
@@ -35,6 +37,7 @@ public class DistanceDemoActivity extends BaseDemoActivity
private Marker mMarkerA;
private Marker mMarkerB;
private Polyline mPolyline;
+ private UnitsConverter mUnitsConverter;
@Override
protected int getLayoutId() {
@@ -44,6 +47,7 @@ protected int getLayoutId() {
@Override
protected void startDemo(boolean isRestore) {
mTextView = findViewById(R.id.textView);
+ mUnitsConverter = Units.getUnitsConverter();
if (!isRestore) {
getMap().moveCamera(CameraUpdateFactory.newLatLngZoom(new LatLng(-33.8256, 151.2395), 10));
@@ -62,30 +66,21 @@ protected void startDemo(boolean isRestore) {
showDistance();
}
+ // [START maps_android_utils_units_java]
@SuppressLint("SetTextI18n")
private void showDistance() {
- double distance =
+ double distanceMeters =
SphericalUtil.computeDistanceBetween(mMarkerA.getPosition(), mMarkerB.getPosition());
- mTextView.setText(getString(R.string.distance_format, formatNumber(distance)));
+ String formattedDistance = mUnitsConverter.toDistanceString(distanceMeters);
+ mTextView.setText(getString(R.string.distance_format, formattedDistance));
}
+ // [END maps_android_utils_units_java]
+
private void updatePolyline() {
mPolyline.setPoints(Arrays.asList(mMarkerA.getPosition(), mMarkerB.getPosition()));
}
- private String formatNumber(double distance) {
- String unit = getString(R.string.unit_m);
- if (distance < 1) {
- distance *= 1000;
- unit = getString(R.string.unit_mm);
- } else if (distance > 1000) {
- distance /= 1000;
- unit = getString(R.string.unit_km);
- }
-
- return String.format("%4.3f%s", distance, unit);
- }
-
@Override
public void onMarkerDragEnd(@NonNull Marker marker) {
showDistance();
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
index baa92de1f..fd7298f5d 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/MainActivity.kt
@@ -137,6 +137,7 @@ internal fun demoGroups(): List = listOf(
Demo(R.string.demo_title_tile_provider, TileProviderAndProjectionDemo::class.java),
Demo(R.string.demo_title_animation_util, AnimationUtilDemoActivity::class.java),
Demo(R.string.demo_title_reactive_extensions, KtxExtensionsDemoActivity::class.java),
+ Demo(R.string.demo_title_units, UnitsDemoActivity::class.java),
),
),
DemoGroup(
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
new file mode 100644
index 000000000..7f30307b2
--- /dev/null
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android.utils.demo
+
+import android.graphics.Color
+import android.view.View
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.LatLngBounds
+import com.google.android.gms.maps.model.Marker
+import com.google.android.gms.maps.model.MarkerOptions
+import com.google.android.gms.maps.model.PolylineOptions
+import com.google.maps.android.Meters
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.km
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.sphericalPathLength
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import java.util.Locale
+
+// [START maps_android_utils_units_kotlin]
+/**
+ * Demonstrates how to use [Meters], [UnitSystem], [UnitsConverter], [getUnitsConverter],
+ * [toDistanceString], and [toElevationString] to format distances and elevations in either
+ * the device's default locale unit system or an explicit Metric / Imperial override.
+ */
+class UnitsDemoActivity : BaseDemoActivity() {
+
+ // [START maps_android_utils_units_demo_model]
+ /**
+ * Domain model for a mountain summit using the type-safe [Meters] inline value class.
+ */
+ data class MountainPeak(val name: String, val position: LatLng, val elevation: Meters)
+
+ /**
+ * User-selectable unit preference mode, allowing automatic locale detection or explicit
+ * Metric / Imperial selection.
+ */
+ enum class UnitPreference {
+ DEVICE_DEFAULT,
+ METRIC,
+ IMPERIAL,
+ ;
+
+ fun resolveConverter(locale: Locale = Locale.getDefault()): UnitsConverter = when (this) {
+ DEVICE_DEFAULT -> getUnitsConverter(locale)
+ METRIC -> getUnitsConverter(UnitSystem.METRIC)
+ IMPERIAL -> getUnitsConverter(UnitSystem.IMPERIAL)
+ }
+ }
+
+ private val peaks = listOf(
+ MountainPeak("Mount Elbert", LatLng(39.1178, -106.4454), 4401.2.meters),
+ MountainPeak("Mount Blue Sky", LatLng(39.5883, -105.6438), 4348.0.meters),
+ MountainPeak("Longs Peak", LatLng(40.2550, -105.6151), 4346.0.meters),
+ MountainPeak("Pikes Peak", LatLng(38.8409, -105.0423), 4302.3.meters),
+ )
+
+ private val sampleDistances = listOf(
+ "Short (< 0.25 mi)" to 150.meters,
+ "Quarter mile (0.25 mi)" to 0.25.miles,
+ "Medium (2.0 km)" to 2.0.km,
+ "Multi-mile (2.5 mi)" to 2.5.miles,
+ "Long (>= 10 km)" to 12.4.km,
+ )
+ // [END maps_android_utils_units_demo_model]
+
+ private val peakMarkers = mutableListOf>()
+ private var selectedPreference by mutableStateOf(UnitPreference.DEVICE_DEFAULT)
+
+ override fun getLayoutId(): Int = R.layout.activity_units_demo
+
+ override fun startDemo(isRestore: Boolean) {
+ val currentMap = map ?: return
+
+ val coordinates = peaks.map { it.position }
+ currentMap.addPolyline(
+ PolylineOptions()
+ .addAll(coordinates)
+ .color(Color.parseColor("#1A73E8"))
+ .width(10f)
+ .geodesic(true),
+ )
+
+ peakMarkers.clear()
+ val converter = selectedPreference.resolveConverter()
+ for (peak in peaks) {
+ val marker = currentMap.addMarker(
+ MarkerOptions()
+ .position(peak.position)
+ .title(peak.name)
+ .snippet(converter.toElevationString(resources, peak.elevation)),
+ )
+ if (marker != null) {
+ peakMarkers.add(peak to marker)
+ }
+ }
+ peakMarkers.firstOrNull()?.second?.showInfoWindow()
+
+ val bounds = LatLngBounds.builder().apply {
+ coordinates.forEach { include(it) }
+ }.build()
+ var hasCenteredCamera = isRestore
+
+ val composeView = findViewById(R.id.units_control_panel)
+ composeView.setContent {
+ MaterialTheme {
+ UnitsControlPanel(
+ preference = selectedPreference,
+ onPreferenceSelected = { newPreference ->
+ selectedPreference = newPreference
+ refreshMarkerSnippets(newPreference.resolveConverter())
+ },
+ onPanelHeightChanged = { heightPx ->
+ if (heightPx > 0) {
+ currentMap.setPadding(0, 0, 0, heightPx)
+ if (!hasCenteredCamera) {
+ hasCenteredCamera = true
+ currentMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 140))
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+
+ private fun refreshMarkerSnippets(converter: UnitsConverter) {
+ for ((peak, marker) in peakMarkers) {
+ marker.snippet = converter.toElevationString(resources, peak.elevation)
+ if (marker.isInfoWindowShown) {
+ marker.showInfoWindow()
+ }
+ }
+ }
+
+ @Composable
+ private fun UnitsControlPanel(
+ preference: UnitPreference,
+ onPreferenceSelected: (UnitPreference) -> Unit,
+ onPanelHeightChanged: (Int) -> Unit = {},
+ ) {
+ // [START maps_android_utils_units_demo_formatting]
+ val converter = preference.resolveConverter()
+ val firstLegDistance: Meters = peaks[0].position.sphericalDistance(peaks[1].position).meters
+ val totalRouteDistance: Meters = peaks.map { it.position }.sphericalPathLength().meters
+ val elevationDifference: Meters = peaks[0].elevation - peaks.last().elevation
+ // [END maps_android_utils_units_demo_formatting]
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ .onSizeChanged { size -> onPanelHeightChanged(size.height) },
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.units_demo_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ text = stringResource(
+ R.string.units_demo_active_system,
+ converter.unitSystem.name,
+ Locale.getDefault().country.ifEmpty { "default" },
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(
+ selected = preference == UnitPreference.DEVICE_DEFAULT,
+ onClick = { onPreferenceSelected(UnitPreference.DEVICE_DEFAULT) },
+ label = { Text(stringResource(R.string.units_pref_default)) },
+ )
+ FilterChip(
+ selected = preference == UnitPreference.METRIC,
+ onClick = { onPreferenceSelected(UnitPreference.METRIC) },
+ label = { Text(stringResource(R.string.units_pref_metric)) },
+ )
+ FilterChip(
+ selected = preference == UnitPreference.IMPERIAL,
+ onClick = { onPreferenceSelected(UnitPreference.IMPERIAL) },
+ label = { Text(stringResource(R.string.units_pref_imperial)) },
+ )
+ }
+
+ HorizontalDivider()
+
+ SummaryRow(
+ label = "${peaks[0].name} Elevation",
+ value = peaks[0].elevation.toElevationString(converter),
+ )
+ SummaryRow(
+ label = "Elevation Delta (${peaks[0].name} → ${peaks.last().name})",
+ value = elevationDifference.toElevationString(converter),
+ )
+ SummaryRow(
+ label = "Leg 1 (${peaks[0].name} → ${peaks[1].name})",
+ value = firstLegDistance.toDistanceString(converter),
+ )
+ SummaryRow(
+ label = "Total Geodesic Route",
+ value = totalRouteDistance.toDistanceString(converter),
+ )
+
+ HorizontalDivider()
+
+ Text(
+ text = stringResource(R.string.units_demo_logical_formatting_header),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ for ((label, distance) in sampleDistances) {
+ SummaryRow(
+ label = label,
+ value = distance.toDistanceString(converter),
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun SummaryRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+}
+// [END maps_android_utils_units_kotlin]
diff --git a/demo/src/main/res/layout/activity_units_demo.xml b/demo/src/main/res/layout/activity_units_demo.xml
new file mode 100644
index 000000000..73f8cc53d
--- /dev/null
+++ b/demo/src/main/res/layout/activity_units_demo.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml
index fab100c1e..e7a2b11f3 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demo/src/main/res/values/strings.xml
@@ -77,6 +77,15 @@
Generating tiles
AnimationUtil sample
Reactive Extensions & Builders
+ Units & Distance Formatting
+
+
+ Distance & Elevation Units
+ Active system: %1$s (Device locale: %2$s)
+ Device Default
+ Metric
+ Imperial
+ Logical Precision Examples
Street View Demo
@@ -101,9 +110,6 @@
Drag the markers!
The markers are %s apart.
- mm
- m
- km
%1$d (including %2$s)
diff --git a/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
new file mode 100644
index 000000000..7becd7eda
--- /dev/null
+++ b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android.utils.demo
+
+import com.google.common.truth.Truth.assertThat
+import com.google.maps.android.ImperialUnitsConverter
+import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.utils.demo.UnitsDemoActivity.UnitPreference
+import org.junit.Test
+import java.util.Locale
+
+class UnitsDemoTest {
+ @Test
+ fun unitPreference_resolvesExpectedConverter() {
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.US))
+ .isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.UK))
+ .isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.FRANCE))
+ .isSameInstanceAs(MetricUnitsConverter)
+
+ assertThat(UnitPreference.METRIC.resolveConverter(Locale.US))
+ .isSameInstanceAs(MetricUnitsConverter)
+ assertThat(UnitPreference.IMPERIAL.resolveConverter(Locale.FRANCE))
+ .isSameInstanceAs(ImperialUnitsConverter)
+ }
+}
diff --git a/library/api/library.api b/library/api/library.api
index ca48c5952..67815fdb0 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -110,6 +110,14 @@ public final class com/google/maps/android/GoogleMapKt {
public static final fun polylineClickEvents (Lcom/google/android/gms/maps/GoogleMap;)Lkotlinx/coroutines/flow/Flow;
}
+public final class com/google/maps/android/ImperialUnitsConverter : com/google/maps/android/UnitsConverter {
+ public static final field INSTANCE Lcom/google/maps/android/ImperialUnitsConverter;
+ public final fun getMILES_THRESHOLD-NA-LpbA ()D
+ public fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+}
+
public final class com/google/maps/android/IndoorBuildingFocusedEvent : com/google/maps/android/IndoorChangeEvent {
public static final field INSTANCE Lcom/google/maps/android/IndoorBuildingFocusedEvent;
}
@@ -215,9 +223,67 @@ public final class com/google/maps/android/MathUtil {
public static final fun wrap (DDD)D
}
+public final class com/google/maps/android/Meters : java/lang/Comparable {
+ public static final synthetic fun box-impl (D)Lcom/google/maps/android/Meters;
+ public synthetic fun compareTo (Ljava/lang/Object;)I
+ public fun compareTo-5-sh_3o (D)I
+ public static fun compareTo-5-sh_3o (DD)I
+ public static fun constructor-impl (D)D
+ public static final fun div-5-sh_3o (DD)D
+ public static final fun div-hMkcKeE (DLjava/lang/Number;)D
+ public fun equals (Ljava/lang/Object;)Z
+ public static fun equals-impl (DLjava/lang/Object;)Z
+ public static final fun equals-impl0 (DD)Z
+ public final fun getValue ()D
+ public fun hashCode ()I
+ public static fun hashCode-impl (D)I
+ public static final fun minus-ySF13Qw (DD)D
+ public static final fun plus-ySF13Qw (DD)D
+ public static final fun times-hMkcKeE (DLjava/lang/Number;)D
+ public fun toString ()Ljava/lang/String;
+ public static fun toString-impl (D)Ljava/lang/String;
+ public static final fun unaryMinus-NA-LpbA (D)D
+ public final synthetic fun unbox-impl ()D
+}
+
+public final class com/google/maps/android/MetricUnitsConverter : com/google/maps/android/UnitsConverter {
+ public static final field INSTANCE Lcom/google/maps/android/MetricUnitsConverter;
+ public final fun getKILOMETERS_THRESHOLD-NA-LpbA ()D
+ public fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+}
+
public abstract interface annotation class com/google/maps/android/MoveStartedReason : java/lang/annotation/Annotation {
}
+public final class com/google/maps/android/NumberFormatSymbols {
+ public static final field Companion Lcom/google/maps/android/NumberFormatSymbols$Companion;
+ public static final field DEFAULT Lcom/google/maps/android/NumberFormatSymbols;
+ public fun ()V
+ public fun (CCI)V
+ public synthetic fun (CCIILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun component1 ()C
+ public final fun component2 ()C
+ public final fun component3 ()I
+ public final fun copy (CCI)Lcom/google/maps/android/NumberFormatSymbols;
+ public static synthetic fun copy$default (Lcom/google/maps/android/NumberFormatSymbols;CCIILjava/lang/Object;)Lcom/google/maps/android/NumberFormatSymbols;
+ public fun equals (Ljava/lang/Object;)Z
+ public static final fun fromLocale ()Lcom/google/maps/android/NumberFormatSymbols;
+ public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/NumberFormatSymbols;
+ public final fun getDecimalSeparator ()C
+ public final fun getGroupingSeparator ()C
+ public final fun getGroupingSize ()I
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/google/maps/android/NumberFormatSymbols$Companion {
+ public final fun fromLocale ()Lcom/google/maps/android/NumberFormatSymbols;
+ public final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/NumberFormatSymbols;
+ public static synthetic fun fromLocale$default (Lcom/google/maps/android/NumberFormatSymbols$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/NumberFormatSymbols;
+}
+
public abstract class com/google/maps/android/OnMarkerDragEvent {
public abstract fun getMarker ()Lcom/google/android/gms/maps/model/Marker;
}
@@ -258,6 +324,15 @@ public final class com/google/maps/android/PolylineKt {
public static final fun getSphericalPathLength (Lcom/google/android/gms/maps/model/Polyline;)D
}
+public final class com/google/maps/android/R$string {
+ public static field amu_in_feet I
+ public static field amu_in_kilometers I
+ public static field amu_in_kilometers_whole I
+ public static field amu_in_meters I
+ public static field amu_in_miles I
+ public static field amu_in_miles_whole I
+}
+
public final class com/google/maps/android/ResponseStreetView {
public fun (Lcom/google/maps/android/Status;)V
public final fun component1 ()Lcom/google/maps/android/Status;
@@ -363,6 +438,109 @@ public final class com/google/maps/android/ThirdDimension : java/lang/Enum {
public static fun values ()[Lcom/google/maps/android/ThirdDimension;
}
+public final class com/google/maps/android/UnitSystem : java/lang/Enum {
+ public static final field Companion Lcom/google/maps/android/UnitSystem$Companion;
+ public static final field IMPERIAL Lcom/google/maps/android/UnitSystem;
+ public static final field METRIC Lcom/google/maps/android/UnitSystem;
+ public static final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public final fun getConverter ()Lcom/google/maps/android/UnitsConverter;
+ public static fun getEntries ()Lkotlin/enums/EnumEntries;
+ public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static fun values ()[Lcom/google/maps/android/UnitSystem;
+}
+
+public final class com/google/maps/android/UnitSystem$Companion {
+ public final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun fromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun getIMPERIAL_COUNTRY_CODES ()Ljava/util/Set;
+}
+
+public final class com/google/maps/android/Units {
+ public static final field FEET_PER_METER D
+ public static final field FEET_PER_MILE I
+ public static final field METERS_PER_FOOT D
+ public static final field METERS_PER_KILOMETER I
+ public static final field METERS_PER_MILE D
+ public static final field MILES_PER_METER D
+ public static final fun formatDistanceValue (D)Ljava/lang/String;
+ public static final fun formatDistanceValue (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun formatDistanceValue (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun formatDistanceValue$default (DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun getFeet (Ljava/lang/Number;)D
+ public static final fun getFt (Ljava/lang/Number;)D
+ public static final fun getKilometers (Ljava/lang/Number;)D
+ public static final fun getKm (Ljava/lang/Number;)D
+ public static final fun getM (Ljava/lang/Number;)D
+ public static final fun getMeters (Ljava/lang/Number;)D
+ public static final fun getMi (Ljava/lang/Number;)D
+ public static final fun getMiles (Ljava/lang/Number;)D
+ public static final fun getToFeet-5-sh_3o (D)D
+ public static final fun getToKilometers-5-sh_3o (D)D
+ public static final fun getToMeters-5-sh_3o (D)D
+ public static final fun getToMiles-5-sh_3o (D)D
+ public static final fun getUnitsConverter ()Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun toDistanceString-iR1Jf8I (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toDistanceString-iR1Jf8I (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toDistanceString-iR1Jf8I (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toDistanceString-iR1Jf8I$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString-iR1Jf8I$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString-iR1Jf8I$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun toElevationString-iR1Jf8I (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toElevationString-iR1Jf8I (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toElevationString-iR1Jf8I (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toElevationString-iR1Jf8I$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString-iR1Jf8I$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString-iR1Jf8I$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+}
+
+public abstract class com/google/maps/android/UnitsConverter {
+ public fun ()V
+ public abstract fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public final fun toDistanceString (D)Ljava/lang/String;
+ public final fun toDistanceString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toDistanceString (DLjava/util/Locale;)Ljava/lang/String;
+ public final fun toDistanceString (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public final fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toDistanceString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun toDistanceString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun toDistanceUnits (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public abstract fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public final fun toElevationString (D)Ljava/lang/String;
+ public final fun toElevationString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toElevationString (DLjava/util/Locale;)Ljava/lang/String;
+ public final fun toElevationString (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public final fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public final fun toElevationString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
+ public static synthetic fun toElevationString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
+ public final fun toElevationUnits (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public abstract fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+}
+
+public final class com/google/maps/android/ValueWithUnitsTemplate {
+ public fun (DI)V
+ public final fun component1 ()D
+ public final fun component2 ()I
+ public final fun copy (DI)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public static synthetic fun copy$default (Lcom/google/maps/android/ValueWithUnitsTemplate;DIILjava/lang/Object;)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getUnitsTemplate ()I
+ public final fun getValue ()D
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
public class com/google/maps/android/collections/CircleManager : com/google/maps/android/collections/MapObjectManager, com/google/android/gms/maps/GoogleMap$OnCircleClickListener {
public fun (Lcom/google/android/gms/maps/GoogleMap;)V
public fun newCollection ()Lcom/google/maps/android/collections/CircleManager$Collection;
diff --git a/library/src/main/java/com/google/maps/android/Units.kt b/library/src/main/java/com/google/maps/android/Units.kt
new file mode 100644
index 000000000..9dc4cc2da
--- /dev/null
+++ b/library/src/main/java/com/google/maps/android/Units.kt
@@ -0,0 +1,764 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:JvmName("Units")
+
+package com.google.maps.android
+
+import android.content.res.Resources
+import androidx.annotation.StringRes
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import java.text.NumberFormat
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.roundToLong
+
+// =================================================================================================
+// § 1. Physical Conversion Constants
+// =================================================================================================
+//
+// By international agreement (1959 International Yard and Pound Agreement), one international foot
+// is defined as exactly 0.3048 meters (12 inches * 2.54 cm/inch / 100 cm/meter).
+// All derived imperial-to-metric and metric-to-imperial conversion constants below are grounded in
+// this exact definition so that round-trip conversions (e.g., `1.miles.toMiles`, `5280.feet.toMiles`)
+// remain mathematically consistent.
+
+// [START maps_android_utils_units_constants]
+/** Exact number of meters in one international foot (`0.3048`). */
+public const val METERS_PER_FOOT: Double = 0.3048
+
+/** Number of meters in one kilometer (`1,000`). */
+public const val METERS_PER_KILOMETER: Int = 1_000
+
+/** Number of international feet in one meter (`~3.280839895013123`). */
+public const val FEET_PER_METER: Double = 1.0 / METERS_PER_FOOT
+
+/** Number of feet in one statute mile (`5,280`). */
+public const val FEET_PER_MILE: Int = 5_280
+
+/** Exact number of meters in one statute mile (`1,609.344`). */
+public const val METERS_PER_MILE: Double = FEET_PER_MILE * METERS_PER_FOOT
+
+/** Number of statute miles in one meter (`~0.000621371192237334`). */
+public const val MILES_PER_METER: Double = 1.0 / METERS_PER_MILE
+// [END maps_android_utils_units_constants]
+
+// =================================================================================================
+// § 2. Type-Safe Length Representation: The `Meters` Value Class
+// =================================================================================================
+//
+// Geographical calculations in the Maps SDK and `SphericalUtil` produce raw `Double` values in
+// meters, whereas UI components and domain models frequently mix feet, miles, meters, and
+// kilometers. Wrapping scalar measurements in an inline `Meters` value class eliminates unit
+// confusion at compile time without incurring heap allocation overhead in Kotlin code.
+
+// [START maps_android_utils_units_meters]
+
+/**
+ * An inline value class representing a length, distance, or elevation measurement in meters.
+ *
+ * Construct instances using the [Number] extension properties ([meters], [m], [kilometers], [km],
+ * [feet], [ft], [miles], [mi]) and convert back to raw scalar units via [toMeters], [toKilometers],
+ * [toFeet], and [toMiles].
+ *
+ * @property value The underlying measurement in meters.
+ */
+@JvmInline
+public value class Meters(public val value: Double) : Comparable {
+ override fun compareTo(other: Meters): Int = value.compareTo(other.value)
+
+ /** Adds another [Meters] measurement to this measurement. */
+ public operator fun plus(other: Meters): Meters = Meters(value = this.value + other.value)
+
+ /** Subtracts another [Meters] measurement from this measurement. */
+ public operator fun minus(other: Meters): Meters = Meters(value = this.value - other.value)
+
+ /** Multiplies this measurement by a scalar [Number]. */
+ public operator fun times(scalar: Number): Meters = Meters(value = this.value * scalar.toDouble())
+
+ /** Divides this measurement by a scalar [Number]. */
+ public operator fun div(scalar: Number): Meters = Meters(value = this.value / scalar.toDouble())
+
+ /** Computes the dimensionless ratio between this measurement and [other]. */
+ public operator fun div(other: Meters): Double = this.value / other.value
+
+ /** Negates this measurement. */
+ public operator fun unaryMinus(): Meters = Meters(value = -this.value)
+}
+
+/** Creates a [Meters] measurement from a [Number] of meters. */
+public inline val Number.meters: Meters
+ get() = Meters(value = this.toDouble())
+
+/** Shorthand to create a [Meters] measurement from a [Number] of meters. */
+public inline val Number.m: Meters
+ get() = Meters(value = this.toDouble())
+
+/** Creates a [Meters] measurement from a [Number] of kilometers. */
+public inline val Number.kilometers: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
+
+/** Shorthand to create a [Meters] measurement from a [Number] of kilometers. */
+public inline val Number.km: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
+
+/** Creates a [Meters] measurement from a [Number] of international feet. */
+public inline val Number.feet: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
+
+/** Shorthand to create a [Meters] measurement from a [Number] of international feet. */
+public inline val Number.ft: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
+
+/** Creates a [Meters] measurement from a [Number] of statute miles. */
+public inline val Number.miles: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_MILE)
+
+/** Shorthand to create a [Meters] measurement from a [Number] of statute miles. */
+public inline val Number.mi: Meters
+ get() = Meters(value = this.toDouble() * METERS_PER_MILE)
+
+/** Returns the value of this [Meters] measurement in meters as a [Double]. */
+public inline val Meters.toMeters: Double
+ get() = value
+
+/** Returns the equivalent number of kilometers for this [Meters] measurement. */
+public inline val Meters.toKilometers: Double
+ get() = value / METERS_PER_KILOMETER
+
+/** Returns the equivalent number of international feet for this [Meters] measurement. */
+public inline val Meters.toFeet: Double
+ get() = value * FEET_PER_METER
+
+/** Returns the equivalent number of statute miles for this [Meters] measurement. */
+public inline val Meters.toMiles: Double
+ get() = value * MILES_PER_METER
+// [END maps_android_utils_units_meters]
+
+// =================================================================================================
+// § 3. Framework-Agnostic Unit System Selection
+// =================================================================================================
+//
+// Developers need a framework-agnostic way to determine whether to display measurements in Metric
+// or Imperial units without coupling business logic to Android `Context` or Jetpack Compose
+// `LocalConfiguration`. `UnitSystem` resolves the appropriate system from any `java.util.Locale`
+// (defaulting to `Locale.getDefault()`) or ISO 3166-1 alpha-2 country code, while allowing
+// applications to explicitly override the user's preference.
+
+// [START maps_android_utils_units_system]
+
+/**
+ * Represents a measurement system used to format distances and elevations.
+ */
+public enum class UnitSystem {
+ /**
+ * The Metric system (meters and kilometers for distances; meters for elevations).
+ */
+ METRIC,
+
+ /**
+ * The Imperial / US customary system (feet and miles for distances; feet for elevations).
+ */
+ IMPERIAL,
+ ;
+
+ /**
+ * Returns the [UnitsConverter] strategy corresponding to this [UnitSystem].
+ */
+ public val converter: UnitsConverter
+ get() = when (this) {
+ METRIC -> MetricUnitsConverter
+ IMPERIAL -> ImperialUnitsConverter
+ }
+
+ public companion object {
+ /**
+ * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for road distances
+ * and elevations: United States (`"US"`), Liberia (`"LR"`), Myanmar (`"MM"`), and
+ * United Kingdom (`"GB"`).
+ */
+ public val IMPERIAL_COUNTRY_CODES: Set = setOf("US", "LR", "MM", "GB")
+
+ /**
+ * Returns the preferred [UnitSystem] for the given [locale].
+ *
+ * Defaults to [Locale.getDefault], providing a framework-agnostic way to select the
+ * device's appropriate unit system without requiring an Android `Context`.
+ *
+ * @param locale The [Locale] whose country code determines the unit system.
+ * @return [IMPERIAL] if the locale's country is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun fromLocale(locale: Locale = Locale.getDefault()): UnitSystem = fromCountryCode(locale.country)
+
+ /**
+ * Returns the preferred [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * Comparison is case-insensitive. If [countryCode] is `null`, blank, or not in
+ * [IMPERIAL_COUNTRY_CODES], [METRIC] is returned.
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return The corresponding [UnitSystem].
+ */
+ @JvmStatic
+ public fun fromCountryCode(countryCode: String?): UnitSystem {
+ if (countryCode.isNullOrBlank()) return METRIC
+ return if (countryCode.uppercase() in IMPERIAL_COUNTRY_CODES) {
+ IMPERIAL
+ } else {
+ METRIC
+ }
+ }
+ }
+}
+// [END maps_android_utils_units_system]
+
+/**
+ * Platform-agnostic symbols and grouping rules for formatting numeric measurements.
+ *
+ * Separating locale symbol resolution from the core number formatting algorithm allows distance
+ * and elevation formatting to run in pure Kotlin (`kotlin.math` and `StringBuilder`) without
+ * relying on `java.util.Formatter` (`String.format`), keeping the formatting logic compatible
+ * with Kotlin Multiplatform (`commonMain`).
+ *
+ * @property groupingSeparator Character used to separate thousands groups (e.g., `','` in `en-US`, `'.'` in `de-DE`).
+ * @property decimalSeparator Character used to separate integer and fractional parts (e.g., `'.'` in `en-US`, `','` in `de-DE`).
+ * @property groupingSize Number of digits in each group to the left of the decimal point (typically `3`, or `<= 0` to disable grouping).
+ */
+public data class NumberFormatSymbols(
+ public val groupingSeparator: Char = ',',
+ public val decimalSeparator: Char = '.',
+ public val groupingSize: Int = 3,
+) {
+ public companion object {
+ /**
+ * Standard English number formatting symbols (`','` thousands separator, `'.'` decimal
+ * separator, group size `3`).
+ */
+ @JvmField
+ public val DEFAULT: NumberFormatSymbols = NumberFormatSymbols()
+
+ /**
+ * Resolves the [NumberFormatSymbols] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose grouping and decimal rules should be used.
+ * @return The corresponding [NumberFormatSymbols].
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun fromLocale(locale: Locale = Locale.getDefault()): NumberFormatSymbols {
+ val dfs = DecimalFormatSymbols.getInstance(locale)
+ val decimalFormat = NumberFormat.getNumberInstance(locale) as? DecimalFormat
+ val groupingSize = if (decimalFormat != null && decimalFormat.isGroupingUsed) {
+ decimalFormat.groupingSize
+ } else {
+ 3
+ }
+ return NumberFormatSymbols(
+ groupingSeparator = dfs.groupingSeparator,
+ decimalSeparator = dfs.decimalSeparator,
+ groupingSize = groupingSize,
+ )
+ }
+ }
+}
+
+// =================================================================================================
+// § 4. Logical Distance & Elevation Formatting
+// =================================================================================================
+//
+// When displaying distances in larger units (kilometers or miles), fixed-precision formatting
+// either loses detail at short range (e.g., truncating 1.5 km to "1 km") or introduces noisy
+// fractional digits at long range (e.g., "142.3 km").
+//
+// To maintain at least two significant digits across orders of magnitude:
+// 1. Small units (`m` below 1 km, `ft` below 0.25 mi, and all elevations) are formatted as whole
+// integers (`0` decimal places, e.g., `"450 m"`, `"528 ft"`, `"14,440 ft"`).
+// 2. Large units (`km` or `mi`) under 10 units are formatted to `1` decimal place, keeping the
+// trailing `.0` (e.g., `"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+// 3. Large units (`km` or `mi`) of 10 units or greater are rounded to whole integers (`0` decimal
+// places, e.g., `"10 km"`, `"12 mi"`).
+
+/**
+ * Pairs a converted numeric measurement [value] with the Android string resource ID
+ * ([unitsTemplate]) used to format it.
+ *
+ * @property value The converted numerical value in the target unit (e.g., feet, miles, meters, or kilometers).
+ * @property unitsTemplate The `@StringRes` resource ID for formatting [value] with its unit label.
+ */
+public data class ValueWithUnitsTemplate(
+ public val value: Double,
+ @param:StringRes @field:StringRes @get:StringRes public val unitsTemplate: Int,
+)
+
+/**
+ * Strategy for converting and formatting [Meters] measurements into human-readable distance and
+ * elevation strings.
+ *
+ * Obtain an instance via [getUnitsConverter], [UnitSystem.converter], or directly reference
+ * [MetricUnitsConverter] or [ImperialUnitsConverter].
+ */
+public abstract class UnitsConverter {
+ /** The [UnitSystem] implemented by this converter. */
+ public abstract val unitSystem: UnitSystem
+
+ /**
+ * Converts a distance in [meters] to a [ValueWithUnitsTemplate] containing the scaled numeric
+ * value and the appropriate string resource template ID.
+ */
+ public abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate
+
+ /**
+ * Converts a distance in [meters] (as a raw [Double]) to a [ValueWithUnitsTemplate].
+ */
+ public fun toDistanceUnits(meters: Double): ValueWithUnitsTemplate = toDistanceUnits(Meters(meters))
+
+ /**
+ * Converts an elevation in [meters] to a [ValueWithUnitsTemplate] containing the scaled numeric
+ * value and the appropriate string resource template ID.
+ */
+ public abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate
+
+ /**
+ * Converts an elevation in [meters] (as a raw [Double]) to a [ValueWithUnitsTemplate].
+ */
+ public fun toElevationUnits(meters: Double): ValueWithUnitsTemplate = toElevationUnits(Meters(meters))
+
+ /**
+ * Formats a distance in [meters] into a human-readable string with units using pure Kotlin
+ * number formatting and the given [symbols].
+ *
+ * @param meters The distance to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"10 km"`, `"528 ft"`, `"2.5 mi"`).
+ */
+ public fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String {
+ val (value, resourceId) = toDistanceUnits(meters)
+ return formatWithTemplate(resourceId, value, symbols)
+ }
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) using the given [symbols].
+ *
+ * @param meters The distance in meters to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string.
+ */
+ public fun toDistanceString(meters: Double, symbols: NumberFormatSymbols): String =
+ toDistanceString(Meters(meters), symbols)
+
+ /**
+ * Formats a distance in [meters] into a localized string with units without requiring Android
+ * [Resources].
+ *
+ * @param meters The distance to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"10 km"`, `"528 ft"`, `"2.5 mi"`).
+ */
+ public fun toDistanceString(meters: Meters, locale: Locale = Locale.getDefault()): String =
+ toDistanceString(meters, NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) into a localized string with units without
+ * requiring Android [Resources].
+ *
+ * @param meters The distance in meters to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted distance string.
+ */
+ @JvmOverloads
+ public fun toDistanceString(meters: Double, locale: Locale = Locale.getDefault()): String =
+ toDistanceString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats a distance in [meters] using the provided Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param meters The distance to format.
+ * @return The formatted distance string.
+ */
+ public fun toDistanceString(resources: Resources, meters: Meters): String {
+ val (value, resourceId) = toDistanceUnits(meters = meters)
+ return resources.getString(resourceId, value)
+ }
+
+ /**
+ * Formats a distance in [meters] (as a raw [Double]) using the provided Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param meters The distance in meters to format.
+ * @return The formatted distance string.
+ */
+ public fun toDistanceString(resources: Resources, meters: Double): String =
+ toDistanceString(resources, Meters(meters))
+
+ /**
+ * Formats an elevation in [meters] into a human-readable string with units using pure Kotlin
+ * number formatting and the given [symbols].
+ *
+ * @param meters The elevation to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string (e.g., `"4,401 m"` or `"14,440 ft"`).
+ */
+ public fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String {
+ val (value, resourceId) = toElevationUnits(meters)
+ return formatWithTemplate(resourceId, value, symbols)
+ }
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) using the given [symbols].
+ *
+ * @param meters The elevation in meters to format.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string.
+ */
+ public fun toElevationString(meters: Double, symbols: NumberFormatSymbols): String =
+ toElevationString(Meters(meters), symbols)
+
+ /**
+ * Formats an elevation in [meters] into a localized string with units without requiring Android
+ * [Resources].
+ *
+ * @param meters The elevation to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted elevation string (e.g., `"4,401 m"` or `"14,440 ft"`).
+ */
+ public fun toElevationString(meters: Meters, locale: Locale = Locale.getDefault()): String =
+ toElevationString(meters, NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) into a localized string with units
+ * without requiring Android [Resources].
+ *
+ * @param meters The elevation in meters to format.
+ * @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
+ * @return The formatted elevation string.
+ */
+ @JvmOverloads
+ public fun toElevationString(meters: Double, locale: Locale = Locale.getDefault()): String =
+ toElevationString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
+
+ /**
+ * Formats an elevation in [meters] using the provided Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param meters The elevation to format.
+ * @return The formatted elevation string.
+ */
+ public fun toElevationString(resources: Resources, meters: Meters): String {
+ val (value, resourceId) = toElevationUnits(meters = meters)
+ return resources.getString(resourceId, value)
+ }
+
+ /**
+ * Formats an elevation in [meters] (as a raw [Double]) using the provided Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param meters The elevation in meters to format.
+ * @return The formatted elevation string.
+ */
+ public fun toElevationString(resources: Resources, meters: Double): String =
+ toElevationString(resources, Meters(meters))
+}
+
+// [START maps_android_utils_units_converters]
+
+/**
+ * Renders distance and elevation measurements in Imperial units (`ft` and `mi`).
+ *
+ * - **Distances** below `0.25` miles (`1,320` feet) are displayed in whole feet (`ft`).
+ * - **Distances** from `0.25` miles up to `< 10` miles are displayed in miles (`mi`) with `1`
+ * decimal digit (keeping trailing `.0`, e.g., `"0.3 mi"`, `"1.0 mi"`, `"2.5 mi"`).
+ * - **Distances** of `10` miles or greater are displayed in whole miles (`mi`, e.g., `"10 mi"`).
+ * - **Elevations** are always displayed in whole feet (`ft`).
+ */
+public object ImperialUnitsConverter : UnitsConverter() {
+ /** Threshold (`0.25` miles = `1,320` feet) at which imperial distances switch from feet to miles. */
+ public val MILES_THRESHOLD: Meters = 0.25.miles
+
+ override val unitSystem: UnitSystem = UnitSystem.IMPERIAL
+
+ override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate =
+ if (abs(meters.value) < MILES_THRESHOLD.value) {
+ ValueWithUnitsTemplate(normalizeZero(meters.toFeet, decimals = 0), R.string.amu_in_feet)
+ } else {
+ val miles = meters.toMiles
+ if (isUnderTenAtOneDecimal(miles)) {
+ ValueWithUnitsTemplate(normalizeZero(miles, decimals = 1), R.string.amu_in_miles)
+ } else {
+ ValueWithUnitsTemplate(normalizeZero(miles, decimals = 0), R.string.amu_in_miles_whole)
+ }
+ }
+
+ override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate =
+ ValueWithUnitsTemplate(normalizeZero(meters.toFeet, decimals = 0), R.string.amu_in_feet)
+}
+
+/**
+ * Renders distance and elevation measurements in Metric units (`m` and `km`).
+ *
+ * - **Distances** below `1,000` meters are displayed in whole meters (`m`).
+ * - **Distances** from `1,000` meters (`1.0` km) up to `< 10` kilometers are displayed in
+ * kilometers (`km`) with `1` decimal digit (keeping trailing `.0`, e.g., `"1.0 km"`, `"2.5 km"`).
+ * - **Distances** of `10` kilometers or greater are displayed in whole kilometers (`km`, e.g., `"10 km"`).
+ * - **Elevations** are always displayed in whole meters (`m`).
+ */
+public object MetricUnitsConverter : UnitsConverter() {
+ /** Threshold (`1,000` meters = `1` kilometer) at which metric distances switch from meters to kilometers. */
+ public val KILOMETERS_THRESHOLD: Meters = METERS_PER_KILOMETER.meters
+
+ override val unitSystem: UnitSystem = UnitSystem.METRIC
+
+ override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate =
+ if (abs(meters.value) < KILOMETERS_THRESHOLD.value) {
+ ValueWithUnitsTemplate(normalizeZero(meters.toMeters, decimals = 0), R.string.amu_in_meters)
+ } else {
+ val kilometers = meters.toKilometers
+ if (isUnderTenAtOneDecimal(kilometers)) {
+ ValueWithUnitsTemplate(normalizeZero(kilometers, decimals = 1), R.string.amu_in_kilometers)
+ } else {
+ ValueWithUnitsTemplate(normalizeZero(kilometers, decimals = 0), R.string.amu_in_kilometers_whole)
+ }
+ }
+
+ override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate =
+ ValueWithUnitsTemplate(normalizeZero(meters.toMeters, decimals = 0), R.string.amu_in_meters)
+}
+// [END maps_android_utils_units_converters]
+
+// =================================================================================================
+// § 5. Top-Level Factory & Extension Functions
+// =================================================================================================
+
+/**
+ * Returns the appropriate [UnitsConverter] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] used to determine the preferred unit system.
+ * @return [ImperialUnitsConverter] for imperial locales (`US`, `LR`, `MM`, `GB`), or
+ * [MetricUnitsConverter] otherwise.
+ */
+@JvmOverloads
+public fun getUnitsConverter(locale: Locale = Locale.getDefault()): UnitsConverter =
+ UnitSystem.fromLocale(locale).converter
+
+/**
+ * Returns the appropriate [UnitsConverter] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * @param countryCode The two-letter country code (case-insensitive), or `null`.
+ * @return [ImperialUnitsConverter] if [countryCode] is in [UnitSystem.IMPERIAL_COUNTRY_CODES],
+ * otherwise [MetricUnitsConverter].
+ */
+public fun getUnitsConverter(countryCode: String?): UnitsConverter = UnitSystem.fromCountryCode(countryCode).converter
+
+/**
+ * Returns the [UnitsConverter] corresponding to the specified [unitSystem].
+ *
+ * @param unitSystem The desired [UnitSystem].
+ * @return [MetricUnitsConverter] or [ImperialUnitsConverter].
+ */
+public fun getUnitsConverter(unitSystem: UnitSystem): UnitsConverter = unitSystem.converter
+
+/**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and pure Kotlin arithmetic:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit, keeping
+ * the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits and thousands grouping
+ * according to [symbols] (e.g., `"10"`, `"1,250"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted numeric string without a unit suffix.
+ */
+public fun formatDistanceValue(value: Double, symbols: NumberFormatSymbols): String =
+ if (isUnderTenAtOneDecimal(value)) {
+ formatFixedDecimal(value, decimals = 1, symbols = symbols)
+ } else {
+ formatFixedDecimal(value, decimals = 0, symbols = symbols)
+ }
+
+/**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and the formatting rules of [locale]:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit, keeping
+ * the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits (e.g., `"10"`, `"42"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @param locale The [Locale] whose grouping and decimal rules are applied (defaults to [Locale.getDefault]).
+ * @return The formatted numeric string without a unit suffix.
+ */
+@JvmOverloads
+public fun formatDistanceValue(value: Double, locale: Locale = Locale.getDefault()): String =
+ formatDistanceValue(value, NumberFormatSymbols.fromLocale(locale))
+
+/**
+ * Formats this [Meters] measurement as a human-readable distance string using [locale] and [converter].
+ *
+ * @param locale The [Locale] used for unit selection and number formatting (defaults to [Locale.getDefault]).
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
+ * @return The formatted distance string.
+ */
+public fun Meters.toDistanceString(
+ locale: Locale = Locale.getDefault(),
+ converter: UnitsConverter = getUnitsConverter(locale),
+): String = converter.toDistanceString(this, locale)
+
+/**
+ * Formats this [Meters] measurement as a human-readable distance string using the specified
+ * [converter] and [symbols].
+ *
+ * @param converter The [UnitsConverter] to use.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted distance string.
+ */
+public fun Meters.toDistanceString(
+ converter: UnitsConverter,
+ symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
+): String = converter.toDistanceString(this, symbols)
+
+/**
+ * Formats this [Meters] measurement as a human-readable distance string using Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
+ * @return The formatted distance string.
+ */
+public fun Meters.toDistanceString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
+ converter.toDistanceString(resources, this)
+
+/**
+ * Formats this [Meters] measurement as a human-readable elevation string using [locale] and [converter].
+ *
+ * @param locale The [Locale] used for unit selection and number formatting (defaults to [Locale.getDefault]).
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
+ * @return The formatted elevation string.
+ */
+public fun Meters.toElevationString(
+ locale: Locale = Locale.getDefault(),
+ converter: UnitsConverter = getUnitsConverter(locale),
+): String = converter.toElevationString(this, locale)
+
+/**
+ * Formats this [Meters] measurement as a human-readable elevation string using the specified
+ * [converter] and [symbols].
+ *
+ * @param converter The [UnitsConverter] to use.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted elevation string.
+ */
+public fun Meters.toElevationString(
+ converter: UnitsConverter,
+ symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
+): String = converter.toElevationString(this, symbols)
+
+/**
+ * Formats this [Meters] measurement as a human-readable elevation string using Android [resources].
+ *
+ * @param resources Android [Resources] used to resolve the unit string template.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
+ * @return The formatted elevation string.
+ */
+public fun Meters.toElevationString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
+ converter.toElevationString(resources, this)
+
+// =================================================================================================
+// § 6. Internal Pure-Kotlin Formatting Helpers
+// =================================================================================================
+
+// [START maps_android_utils_units_pure_kotlin_formatter]
+
+/**
+ * Returns `true` if `abs(value)` rounds to less than `10.0` at one decimal place (`< 9.95`).
+ * Values in `[9.95, 10.0)` round to `10` at one decimal place and therefore use the whole-number
+ * format (`"10"`) rather than `"10.0"`.
+ */
+private fun isUnderTenAtOneDecimal(value: Double): Boolean = (abs(value) * 10.0).roundToLong() < 100L
+
+/**
+ * Prevents negative values that round to zero at the given [decimals] precision from formatting
+ * as `"-0"` or `"-0.0"`.
+ */
+private fun normalizeZero(value: Double, decimals: Int): Double {
+ val factor = if (decimals == 0) 1.0 else 10.0
+ if (abs(value) * factor < 0.5) return 0.0
+ return value
+}
+
+/**
+ * Formats [value] with `0` or `1` decimal places and locale-specific thousands grouping using pure
+ * `kotlin.math` integer arithmetic (`roundToLong`, `/`, `%`) and `buildString`, avoiding any
+ * dependency on `java.util.Formatter` (`String.format`).
+ */
+internal fun formatFixedDecimal(
+ value: Double,
+ decimals: Int,
+ symbols: NumberFormatSymbols = NumberFormatSymbols.DEFAULT,
+): String {
+ val factor = if (decimals <= 0) 1L else 10L
+ val scaled = (abs(value) * factor).roundToLong()
+ val isNegative = value < 0.0 && scaled > 0L
+ val integerPart = if (decimals <= 0) scaled else scaled / 10L
+ val groupedInteger = formatGroupedInteger(
+ value = integerPart,
+ groupingSeparator = symbols.groupingSeparator,
+ groupingSize = symbols.groupingSize,
+ )
+ return buildString {
+ if (isNegative) append('-')
+ append(groupedInteger)
+ if (decimals > 0) {
+ append(symbols.decimalSeparator)
+ append(scaled % 10L)
+ }
+ }
+}
+
+/**
+ * Formats a non-negative integer [value] with [groupingSeparator] inserted every [groupingSize]
+ * digits from the right.
+ */
+internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, groupingSize: Int = 3): String {
+ val digits = value.toString()
+ if (groupingSize <= 0 || digits.length <= groupingSize) {
+ return digits
+ }
+ return buildString(digits.length + (digits.length - 1) / groupingSize) {
+ for (i in digits.indices) {
+ if (i > 0 && (digits.length - i) % groupingSize == 0) {
+ append(groupingSeparator)
+ }
+ append(digits[i])
+ }
+ }
+}
+// [END maps_android_utils_units_pure_kotlin_formatter]
+
+/**
+ * Formats [value] with its unit suffix according to [resourceId] and [symbols] without requiring
+ * Android [Resources] or `java.util.Formatter`.
+ */
+private fun formatWithTemplate(@StringRes resourceId: Int, value: Double, symbols: NumberFormatSymbols): String =
+ when (resourceId) {
+ R.string.amu_in_feet -> "${formatFixedDecimal(value, decimals = 0, symbols)} ft"
+ R.string.amu_in_miles -> "${formatFixedDecimal(value, decimals = 1, symbols)} mi"
+ R.string.amu_in_miles_whole -> "${formatFixedDecimal(value, decimals = 0, symbols)} mi"
+ R.string.amu_in_meters -> "${formatFixedDecimal(value, decimals = 0, symbols)} m"
+ R.string.amu_in_kilometers -> "${formatFixedDecimal(value, decimals = 1, symbols)} km"
+ R.string.amu_in_kilometers_whole -> "${formatFixedDecimal(value, decimals = 0, symbols)} km"
+ else -> formatFixedDecimal(value, decimals = 1, symbols)
+ }
diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml
new file mode 100644
index 000000000..59fbbfafa
--- /dev/null
+++ b/library/src/main/res/values/strings.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ %1$,.0f ft
+
+
+ %1$,.1f mi
+
+
+ %1$,.0f mi
+
+
+ %1$,.0f m
+
+
+ %1$,.1f km
+
+
+ %1$,.0f km
+
diff --git a/library/src/test/java/com/google/maps/android/UnitsTest.kt b/library/src/test/java/com/google/maps/android/UnitsTest.kt
new file mode 100644
index 000000000..c28936c0f
--- /dev/null
+++ b/library/src/test/java/com/google/maps/android/UnitsTest.kt
@@ -0,0 +1,364 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android
+
+import android.content.Context
+import android.content.res.Resources
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.util.Locale
+
+/**
+ * Comprehensive unit tests for [Meters], [UnitSystem], [UnitsConverter], [MetricUnitsConverter],
+ * [ImperialUnitsConverter], and logical distance/elevation formatting.
+ */
+@RunWith(RobolectricTestRunner::class)
+class UnitsTest {
+
+ // =============================================================================================
+ // § 1. Physical Conversion Constants & `Meters` Value Class
+ // =============================================================================================
+
+ @Test
+ fun conversionConstants_areMathematicallyConsistent() {
+ assertThat(METERS_PER_FOOT).isEqualTo(0.3048)
+ assertThat(METERS_PER_KILOMETER).isEqualTo(1_000)
+ assertThat(FEET_PER_MILE).isEqualTo(5_280)
+ assertThat(METERS_PER_MILE).isWithin(1e-9).of(1609.344)
+ assertThat(METERS_PER_FOOT * FEET_PER_METER).isWithin(1e-12).of(1.0)
+ assertThat(METERS_PER_MILE * MILES_PER_METER).isWithin(1e-12).of(1.0)
+ }
+
+ @Test
+ fun numberExtensions_constructMetersAccurately() {
+ assertThat(10.meters).isEqualTo(10.m)
+ assertThat(10.meters.value).isEqualTo(10.0)
+
+ assertThat(1.km).isEqualTo(1_000.m)
+ assertThat(2.5.kilometers).isEqualTo(2_500.meters)
+
+ assertThat(10.feet.value).isWithin(1e-9).of(3.048)
+ assertThat(10.ft).isEqualTo(10.feet)
+
+ assertThat(1.miles.value).isWithin(1e-9).of(1609.344)
+ assertThat(1.mi).isEqualTo(1.miles)
+ assertThat(5280.feet.value).isWithin(1e-9).of(1.miles.value)
+ }
+
+ @Test
+ fun metersExtensions_convertToScalarUnitsAccurately() {
+ assertThat(1500.meters.toMeters).isEqualTo(1500.0)
+ assertThat(1234.m.toKilometers).isWithin(1e-6).of(1.234)
+ assertThat(300.meters.toFeet).isWithin(1e-4).of(984.252)
+ assertThat(10.km.toMiles).isWithin(1e-4).of(6.21371)
+
+ // Exact round-trip conversions
+ assertThat(1.miles.toMiles).isWithin(1e-12).of(1.0)
+ assertThat(1.feet.toFeet).isWithin(1e-12).of(1.0)
+ assertThat(5280.feet.toMiles).isWithin(1e-12).of(1.0)
+ }
+
+ @Test
+ fun meters_arithmeticAndComparisons_behaveCorrectly() {
+ val shortDistance = 100.meters
+ val longDistance = 500.meters
+
+ assertThat(shortDistance).isLessThan(longDistance)
+ assertThat(longDistance).isGreaterThan(shortDistance)
+ assertThat(100.meters).isEquivalentAccordingToCompareTo(100.m)
+
+ assertThat(longDistance + shortDistance).isEqualTo(600.meters)
+ assertThat(longDistance - shortDistance).isEqualTo(400.meters)
+ assertThat(shortDistance * 3).isEqualTo(300.meters)
+ assertThat(longDistance / 2).isEqualTo(250.meters)
+ assertThat(longDistance / shortDistance).isEqualTo(5.0)
+ assertThat(-shortDistance).isEqualTo((-100).meters)
+ }
+
+ // =============================================================================================
+ // § 2. Framework-Agnostic `UnitSystem` & Locale Resolution
+ // =============================================================================================
+
+ @Test
+ fun unitSystem_fromCountryCode_returnsImperialForUsLrMmAndGb() {
+ for (code in listOf("US", "us", "LR", "lr", "MM", "mm", "GB", "gb")) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter(code)).isSameInstanceAs(ImperialUnitsConverter)
+ }
+ }
+
+ @Test
+ fun unitSystem_fromCountryCode_returnsMetricForOtherCountriesAndNullOrBlank() {
+ for (code in listOf("CA", "FR", "DE", "IN", "JP", "AU", "BR", "", " ", null)) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter(code)).isSameInstanceAs(MetricUnitsConverter)
+ }
+ }
+
+ @Test
+ fun unitSystem_fromLocale_resolvesAppropriateUnitSystem() {
+ assertThat(UnitSystem.fromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.fromLocale(Locale.UK)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.fromLocale(Locale.Builder().setLanguage("en").setRegion("LR").build()))
+ .isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.fromLocale(Locale.Builder().setLanguage("my").setRegion("MM").build()))
+ .isEqualTo(UnitSystem.IMPERIAL)
+
+ assertThat(UnitSystem.fromLocale(Locale.CANADA)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.fromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.fromLocale(Locale.JAPAN)).isEqualTo(UnitSystem.METRIC)
+ }
+
+ @Test
+ fun getUnitsConverter_defaultsToDeviceLocale() {
+ val originalLocale = Locale.getDefault()
+ try {
+ Locale.setDefault(Locale.US)
+ assertThat(UnitSystem.fromLocale()).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter()).isSameInstanceAs(ImperialUnitsConverter)
+
+ Locale.setDefault(Locale.GERMANY)
+ assertThat(UnitSystem.fromLocale()).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter()).isSameInstanceAs(MetricUnitsConverter)
+ } finally {
+ Locale.setDefault(originalLocale)
+ }
+ }
+
+ @Test
+ fun getUnitsConverter_fromExplicitUnitSystem_returnsExpectedConverter() {
+ assertThat(getUnitsConverter(UnitSystem.METRIC)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(getUnitsConverter(UnitSystem.IMPERIAL)).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(MetricUnitsConverter.unitSystem).isEqualTo(UnitSystem.METRIC)
+ assertThat(ImperialUnitsConverter.unitSystem).isEqualTo(UnitSystem.IMPERIAL)
+ }
+
+ // =============================================================================================
+ // § 3. Logical Distance Formatting: Metric (`m` and `km`)
+ // =============================================================================================
+
+ @Test
+ fun metricDistance_belowOneKilometer_formatsAsWholeMeters() {
+ assertThat(MetricUnitsConverter.toDistanceString(0.meters, Locale.US)).isEqualTo("0 m")
+ assertThat(MetricUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("100 m")
+ assertThat(MetricUnitsConverter.toDistanceString(450.meters, Locale.US)).isEqualTo("450 m")
+ assertThat(MetricUnitsConverter.toDistanceString(999.meters, Locale.US)).isEqualTo("999 m")
+
+ val template = MetricUnitsConverter.toDistanceUnits(450.meters)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_meters)
+ assertThat(template.value).isEqualTo(450.0)
+ }
+
+ @Test
+ fun metricDistance_fromOneToUnderTenKilometers_keepsOneDecimalWithTrailingZero() {
+ assertThat(MetricUnitsConverter.toDistanceString(1_000.meters, Locale.US)).isEqualTo("1.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_010.meters, Locale.US)).isEqualTo("1.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_500.meters, Locale.US)).isEqualTo("1.5 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_900.meters, Locale.US)).isEqualTo("1.9 km")
+ assertThat(MetricUnitsConverter.toDistanceString(2_000.meters, Locale.US)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(9_900.meters, Locale.US)).isEqualTo("9.9 km")
+
+ val template = MetricUnitsConverter.toDistanceUnits(2_000.meters)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_kilometers)
+ assertThat(template.value).isEqualTo(2.0)
+ }
+
+ @Test
+ fun metricDistance_tenKilometersOrGreater_roundsToWholeKilometers() {
+ // 9.96 km rounds to 10 at 1 decimal place, so it should use the whole-number format ("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(9_960.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(10_000.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(10_400.meters, Locale.US)).isEqualTo("10 km")
+ assertThat(MetricUnitsConverter.toDistanceString(11_600.meters, Locale.US)).isEqualTo("12 km")
+ assertThat(MetricUnitsConverter.toDistanceString(1_250.km, Locale.US)).isEqualTo("1,250 km")
+
+ val template = MetricUnitsConverter.toDistanceUnits(10_400.meters)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_kilometers_whole)
+ assertThat(template.value).isEqualTo(10.4)
+ }
+
+ // =============================================================================================
+ // § 4. Logical Distance Formatting: Imperial (`ft` and `mi`, switching at 0.25 miles)
+ // =============================================================================================
+
+ @Test
+ fun imperialDistance_belowQuarterMile_formatsAsWholeFeet() {
+ assertThat(ImperialUnitsConverter.toDistanceString(0.meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("328 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(0.1.miles, Locale.US)).isEqualTo("528 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.feet, Locale.US)).isEqualTo("1,319 ft")
+
+ val template = ImperialUnitsConverter.toDistanceUnits(0.1.miles)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_feet)
+ assertThat(template.value).isWithin(0.01).of(528.0)
+ }
+
+ @Test
+ fun imperialDistance_fromQuarterMileToUnderTenMiles_keepsOneDecimalWithTrailingZero() {
+ // Exactly 0.25 miles (1,320 feet) switches to miles ("0.3 mi" at 1 decimal place)
+ assertThat(ImperialUnitsConverter.toDistanceString(0.25.miles, Locale.US)).isEqualTo("0.3 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(0.5.miles, Locale.US)).isEqualTo("0.5 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(1.0.miles, Locale.US)).isEqualTo("1.0 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(2_000.meters, Locale.US)).isEqualTo("1.2 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(3_200.meters, Locale.US)).isEqualTo("2.0 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(2.5.miles, Locale.US)).isEqualTo("2.5 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(9.9.miles, Locale.US)).isEqualTo("9.9 mi")
+
+ val template = ImperialUnitsConverter.toDistanceUnits(2.5.miles)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_miles)
+ assertThat(template.value).isWithin(1e-9).of(2.5)
+ }
+
+ @Test
+ fun imperialDistance_tenMilesOrGreater_roundsToWholeMiles() {
+ assertThat(ImperialUnitsConverter.toDistanceString(9.96.miles, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(10.0.miles, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(16_100.meters, Locale.US)).isEqualTo("10 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(25.6.miles, Locale.US)).isEqualTo("26 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(1_250.miles, Locale.US)).isEqualTo("1,250 mi")
+
+ val template = ImperialUnitsConverter.toDistanceUnits(10.0.miles)
+ assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_miles_whole)
+ assertThat(template.value).isWithin(1e-9).of(10.0)
+ }
+
+ // =============================================================================================
+ // § 5. Elevation Formatting (`m` and `ft` only)
+ // =============================================================================================
+
+ @Test
+ fun elevationFormatting_alwaysUsesMetersOrFeetRegardlessOfMagnitude() {
+ // Mount Elbert: 4,401.2 m / 14,440 ft
+ val mountElbert = 4401.2.meters
+ assertThat(MetricUnitsConverter.toElevationString(mountElbert, Locale.US)).isEqualTo("4,401 m")
+ assertThat(ImperialUnitsConverter.toElevationString(mountElbert, Locale.US)).isEqualTo("14,440 ft")
+
+ // Mount Everest: 8,848.86 m / 29,032 ft
+ val mountEverest = 8848.86.meters
+ assertThat(MetricUnitsConverter.toElevationString(mountEverest, Locale.US)).isEqualTo("8,849 m")
+ assertThat(ImperialUnitsConverter.toElevationString(mountEverest, Locale.US)).isEqualTo("29,032 ft")
+
+ // Below sea level (Death Valley: -86 m / -282 ft)
+ val deathValley = (-86).meters
+ assertThat(MetricUnitsConverter.toElevationString(deathValley, Locale.US)).isEqualTo("-86 m")
+ assertThat(ImperialUnitsConverter.toElevationString(deathValley, Locale.US)).isEqualTo("-282 ft")
+ }
+
+ @Test
+ fun negativeNearZeroValues_doNotFormatWithNegativeSign() {
+ assertThat(MetricUnitsConverter.toDistanceString((-0.01).meters, Locale.US)).isEqualTo("0 m")
+ assertThat(ImperialUnitsConverter.toDistanceString((-0.01).meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(MetricUnitsConverter.toElevationString((-0.01).meters, Locale.US)).isEqualTo("0 m")
+ assertThat(ImperialUnitsConverter.toElevationString((-0.01).meters, Locale.US)).isEqualTo("0 ft")
+ assertThat(formatDistanceValue(-0.01, Locale.US)).isEqualTo("0.0")
+ }
+
+ // =============================================================================================
+ // § 6. Android `Resources`, Extension Functions, & Raw `Double` Overloads
+ // =============================================================================================
+
+ @Test
+ fun androidResourcesFormatting_matchesPureJvmFormatting() {
+ val context = ApplicationProvider.getApplicationContext()
+ val resources = context.resources
+
+ assertThat(MetricUnitsConverter.toDistanceString(resources, 450.meters)).isEqualTo("450 m")
+ assertThat(MetricUnitsConverter.toDistanceString(resources, 2_000.meters)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toDistanceString(resources, 12_400.meters)).isEqualTo("12 km")
+ assertThat(MetricUnitsConverter.toElevationString(resources, 4401.2.meters)).isEqualTo("4,401 m")
+
+ assertThat(ImperialUnitsConverter.toDistanceString(resources, 0.1.miles)).isEqualTo("528 ft")
+ assertThat(ImperialUnitsConverter.toDistanceString(resources, 2.0.miles)).isEqualTo("2.0 mi")
+ assertThat(ImperialUnitsConverter.toDistanceString(resources, 12.4.miles)).isEqualTo("12 mi")
+ assertThat(ImperialUnitsConverter.toElevationString(resources, 4401.2.meters)).isEqualTo("14,440 ft")
+
+ // Extension functions with Resources
+ assertThat(2_000.meters.toDistanceString(resources, MetricUnitsConverter)).isEqualTo("2.0 km")
+ assertThat(4401.2.meters.toElevationString(resources, ImperialUnitsConverter)).isEqualTo("14,440 ft")
+ }
+
+ @Test
+ fun mockedResourcesFormatting_invokesExpectedResourceTemplates() {
+ val mockResources = mockk()
+ every { mockResources.getString(R.string.amu_in_feet, *anyVararg()) } returns "528 ft"
+ every { mockResources.getString(R.string.amu_in_kilometers, *anyVararg()) } returns "2.5 km"
+
+ assertThat(ImperialUnitsConverter.toDistanceString(mockResources, 0.1.miles)).isEqualTo("528 ft")
+ assertThat(MetricUnitsConverter.toDistanceString(mockResources, 2_500.meters)).isEqualTo("2.5 km")
+ }
+
+ @Test
+ fun metersExtensionsAndDoubleOverloads_formatCorrectly() {
+ // Extension functions with Locale
+ assertThat(2_000.meters.toDistanceString(Locale.US)).isEqualTo("1.2 mi")
+ assertThat(2_000.meters.toDistanceString(Locale.FRANCE)).isEqualTo("2,0 km")
+ assertThat(2_000.meters.toDistanceString(MetricUnitsConverter)).isEqualTo(
+ MetricUnitsConverter.toDistanceString(2_000.meters),
+ )
+
+ assertThat(4401.2.meters.toElevationString(Locale.US)).isEqualTo("14,440 ft")
+ assertThat(4401.2.meters.toElevationString(MetricUnitsConverter)).isEqualTo(
+ MetricUnitsConverter.toElevationString(4401.2.meters),
+ )
+
+ // Raw Double overloads (for Java callers)
+ val context = ApplicationProvider.getApplicationContext()
+ val resources = context.resources
+ assertThat(MetricUnitsConverter.toDistanceUnits(2000.0).value).isEqualTo(2.0)
+ assertThat(MetricUnitsConverter.toElevationUnits(450.0).value).isEqualTo(450.0)
+ assertThat(MetricUnitsConverter.toDistanceString(2000.0, Locale.US)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toElevationString(450.0, Locale.US)).isEqualTo("450 m")
+ assertThat(MetricUnitsConverter.toDistanceString(resources, 2000.0)).isEqualTo("2.0 km")
+ assertThat(MetricUnitsConverter.toElevationString(resources, 450.0)).isEqualTo("450 m")
+
+ // Standalone logical number formatter
+ assertThat(formatDistanceValue(0.25, Locale.US)).isEqualTo("0.3")
+ assertThat(formatDistanceValue(2.0, Locale.US)).isEqualTo("2.0")
+ assertThat(formatDistanceValue(9.94, Locale.US)).isEqualTo("9.9")
+ assertThat(formatDistanceValue(9.96, Locale.US)).isEqualTo("10")
+ assertThat(formatDistanceValue(12.4, Locale.US)).isEqualTo("12")
+ }
+
+ @Test
+ fun numberFormatSymbols_respectsLocaleGroupingAndDecimalRulesInPureKotlin() {
+ val usSymbols = NumberFormatSymbols.fromLocale(Locale.US)
+ assertThat(usSymbols.groupingSeparator).isEqualTo(',')
+ assertThat(usSymbols.decimalSeparator).isEqualTo('.')
+ assertThat(usSymbols.groupingSize).isEqualTo(3)
+ assertThat(MetricUnitsConverter.toElevationString(1_234_567.meters, usSymbols)).isEqualTo("1,234,567 m")
+ assertThat(MetricUnitsConverter.toDistanceString(2_500.0, usSymbols)).isEqualTo("2.5 km")
+
+ val deSymbols = NumberFormatSymbols.fromLocale(Locale.GERMANY)
+ assertThat(deSymbols.groupingSeparator).isEqualTo('.')
+ assertThat(deSymbols.decimalSeparator).isEqualTo(',')
+ assertThat(deSymbols.groupingSize).isEqualTo(3)
+ assertThat(MetricUnitsConverter.toElevationString(4401.2.meters, deSymbols)).isEqualTo("4.401 m")
+ assertThat(MetricUnitsConverter.toElevationString(4401.2, Locale.GERMANY)).isEqualTo("4.401 m")
+ assertThat(MetricUnitsConverter.toDistanceString(2_000.meters, deSymbols)).isEqualTo("2,0 km")
+ assertThat(formatDistanceValue(1_250.4, deSymbols)).isEqualTo("1.250")
+
+ // Custom KMP symbols (e.g. grouping disabled)
+ val ungroupedSymbols = NumberFormatSymbols(groupingSeparator = '_', decimalSeparator = '.', groupingSize = 0)
+ assertThat(ImperialUnitsConverter.toElevationString(4401.2.meters, ungroupedSymbols)).isEqualTo("14440 ft")
+ assertThat(4401.2.meters.toElevationString(ImperialUnitsConverter, ungroupedSymbols)).isEqualTo("14440 ft")
+ assertThat(2_500.meters.toDistanceString(MetricUnitsConverter, ungroupedSymbols)).isEqualTo("2.5 km")
+ }
+}
diff --git a/llm-integration-prompt.md b/llm-integration-prompt.md
index 176e85a23..05564febd 100644
--- a/llm-integration-prompt.md
+++ b/llm-integration-prompt.md
@@ -46,6 +46,12 @@ Used for server-client coordinate compression and distance calculations.
* **Decoding:** `PolyUtil.decode(encodedPathString)`
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
+### Units & Distance/Elevation Formatting
+Used to convert and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or an explicit `UnitSystem`.
+* **Construct `Meters`:** `450.meters`, `2.0.km`, `0.25.miles`, `14440.feet`
+* **Resolve converter:** `val converter = getUnitsConverter()` (defaults to `Locale.getDefault()`), `getUnitsConverter(UnitSystem.IMPERIAL)`, or `getUnitsConverter("US")`
+* **Format strings:** `distance.toDistanceString(converter)` and `elevation.toElevationString(converter)`
+
## 3. Best Practices
1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread.
2. **Custom Renderers:** If the user wants custom cluster icons, extend `DefaultClusterRenderer` and override `onBeforeClusterItemRendered` and `onBeforeClusterRendered`.
From 4b9ea46624a3048cd81a155a54b9f35e347a8461 Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:51:21 -0600
Subject: [PATCH 2/5] fix(demo): read LocalLocale.current.platformLocale in
UnitsControlPanel
---
.../maps/android/utils/demo/UnitsDemoActivity.kt | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
index 7f30307b2..c289dcd01 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
@@ -35,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -179,8 +180,9 @@ class UnitsDemoActivity : BaseDemoActivity() {
onPreferenceSelected: (UnitPreference) -> Unit,
onPanelHeightChanged: (Int) -> Unit = {},
) {
+ val currentLocale = LocalLocale.current.platformLocale
// [START maps_android_utils_units_demo_formatting]
- val converter = preference.resolveConverter()
+ val converter = preference.resolveConverter(currentLocale)
val firstLegDistance: Meters = peaks[0].position.sphericalDistance(peaks[1].position).meters
val totalRouteDistance: Meters = peaks.map { it.position }.sphericalPathLength().meters
val elevationDifference: Meters = peaks[0].elevation - peaks.last().elevation
@@ -206,7 +208,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
text = stringResource(
R.string.units_demo_active_system,
converter.unitSystem.name,
- Locale.getDefault().country.ifEmpty { "default" },
+ currentLocale.country.ifEmpty { "default" },
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -234,19 +236,19 @@ class UnitsDemoActivity : BaseDemoActivity() {
SummaryRow(
label = "${peaks[0].name} Elevation",
- value = peaks[0].elevation.toElevationString(converter),
+ value = peaks[0].elevation.toElevationString(currentLocale, converter),
)
SummaryRow(
label = "Elevation Delta (${peaks[0].name} → ${peaks.last().name})",
- value = elevationDifference.toElevationString(converter),
+ value = elevationDifference.toElevationString(currentLocale, converter),
)
SummaryRow(
label = "Leg 1 (${peaks[0].name} → ${peaks[1].name})",
- value = firstLegDistance.toDistanceString(converter),
+ value = firstLegDistance.toDistanceString(currentLocale, converter),
)
SummaryRow(
label = "Total Geodesic Route",
- value = totalRouteDistance.toDistanceString(converter),
+ value = totalRouteDistance.toDistanceString(currentLocale, converter),
)
HorizontalDivider()
@@ -259,7 +261,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
for ((label, distance) in sampleDistances) {
SummaryRow(
label = label,
- value = distance.toDistanceString(converter),
+ value = distance.toDistanceString(currentLocale, converter),
)
}
}
From 022ebcc0ee3e112b81795727247b9ea1ee3d135e Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Fri, 23 Oct 2026 14:05:07 -0600
Subject: [PATCH 3/5] feat(units): expose clean Java conversion methods and fix
UseKtx lint
---
README.md | 23 ++++
.../android/utils/demo/UnitsDemoActivity.kt | 4 +-
library/api/library.api | 56 ++++----
.../java/com/google/maps/android/Units.kt | 24 ++++
.../google/maps/android/UnitsJavaTest.java | 126 ++++++++++++++++++
5 files changed, 207 insertions(+), 26 deletions(-)
create mode 100644 library/src/test/java/com/google/maps/android/UnitsJavaTest.java
diff --git a/README.md b/README.md
index 55ad6e5f3..19254ab65 100644
--- a/README.md
+++ b/README.md
@@ -206,6 +206,29 @@ val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
val summit = 4401.2.meters.toElevationString(imperial) // "14,440 ft"
```
+In Java, `Units` exposes static conversion and formatting methods (usable with `import static com.google.maps.android.Units.*;`):
+
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+import static com.google.maps.android.Units.toFeet;
+
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String distance = imperial.toDistanceString(miles(2.5)); // "2.5 mi"
+String summit = toElevationString(meters(4401.2)); // "14,440 ft" (in US locale)
+```
+
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
index c289dcd01..061527940 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
@@ -15,7 +15,6 @@
*/
package com.google.maps.android.utils.demo
-import android.graphics.Color
import android.view.View
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -39,6 +38,7 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.core.graphics.toColorInt
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
@@ -117,7 +117,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
currentMap.addPolyline(
PolylineOptions()
.addAll(coordinates)
- .color(Color.parseColor("#1A73E8"))
+ .color("#1A73E8".toColorInt())
.width(10f)
.geodesic(true),
)
diff --git a/library/api/library.api b/library/api/library.api
index 67815fdb0..de63f9083 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -466,39 +466,47 @@ public final class com/google/maps/android/Units {
public static final field METERS_PER_KILOMETER I
public static final field METERS_PER_MILE D
public static final field MILES_PER_METER D
+ public static final fun feet (Ljava/lang/Number;)D
public static final fun formatDistanceValue (D)Ljava/lang/String;
public static final fun formatDistanceValue (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public static final fun formatDistanceValue (DLjava/util/Locale;)Ljava/lang/String;
public static synthetic fun formatDistanceValue$default (DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public static final fun getFeet (Ljava/lang/Number;)D
- public static final fun getFt (Ljava/lang/Number;)D
- public static final fun getKilometers (Ljava/lang/Number;)D
- public static final fun getKm (Ljava/lang/Number;)D
- public static final fun getM (Ljava/lang/Number;)D
- public static final fun getMeters (Ljava/lang/Number;)D
- public static final fun getMi (Ljava/lang/Number;)D
- public static final fun getMiles (Ljava/lang/Number;)D
- public static final fun getToFeet-5-sh_3o (D)D
- public static final fun getToKilometers-5-sh_3o (D)D
- public static final fun getToMeters-5-sh_3o (D)D
- public static final fun getToMiles-5-sh_3o (D)D
+ public static final fun ft (Ljava/lang/Number;)D
public static final fun getUnitsConverter ()Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Ljava/lang/String;)Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Ljava/util/Locale;)Lcom/google/maps/android/UnitsConverter;
public static synthetic fun getUnitsConverter$default (Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
- public static final fun toDistanceString-iR1Jf8I (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
- public static final fun toDistanceString-iR1Jf8I (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
- public static final fun toDistanceString-iR1Jf8I (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
- public static synthetic fun toDistanceString-iR1Jf8I$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
- public static synthetic fun toDistanceString-iR1Jf8I$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
- public static synthetic fun toDistanceString-iR1Jf8I$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
- public static final fun toElevationString-iR1Jf8I (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
- public static final fun toElevationString-iR1Jf8I (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
- public static final fun toElevationString-iR1Jf8I (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
- public static synthetic fun toElevationString-iR1Jf8I$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
- public static synthetic fun toElevationString-iR1Jf8I$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
- public static synthetic fun toElevationString-iR1Jf8I$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun kilometers (Ljava/lang/Number;)D
+ public static final fun km (Ljava/lang/Number;)D
+ public static final fun m (Ljava/lang/Number;)D
+ public static final fun meters (Ljava/lang/Number;)D
+ public static final fun mi (Ljava/lang/Number;)D
+ public static final fun miles (Ljava/lang/Number;)D
+ public static final fun toDistanceString (D)Ljava/lang/String;
+ public static final fun toDistanceString (DLandroid/content/res/Resources;)Ljava/lang/String;
+ public static final fun toDistanceString (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toDistanceString (DLcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toDistanceString (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toDistanceString (DLjava/util/Locale;)Ljava/lang/String;
+ public static final fun toDistanceString (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toDistanceString$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun toElevationString (D)Ljava/lang/String;
+ public static final fun toElevationString (DLandroid/content/res/Resources;)Ljava/lang/String;
+ public static final fun toElevationString (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toElevationString (DLcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static final fun toElevationString (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public static final fun toElevationString (DLjava/util/Locale;)Ljava/lang/String;
+ public static final fun toElevationString (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
+ public static synthetic fun toElevationString$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
+ public static final fun toFeet (D)D
+ public static final fun toKilometers (D)D
+ public static final fun toMeters (D)D
+ public static final fun toMiles (D)D
}
public abstract class com/google/maps/android/UnitsConverter {
diff --git a/library/src/main/java/com/google/maps/android/Units.kt b/library/src/main/java/com/google/maps/android/Units.kt
index 9dc4cc2da..5c7c9ae5f 100644
--- a/library/src/main/java/com/google/maps/android/Units.kt
+++ b/library/src/main/java/com/google/maps/android/Units.kt
@@ -100,50 +100,62 @@ public value class Meters(public val value: Double) : Comparable {
}
/** Creates a [Meters] measurement from a [Number] of meters. */
+@get:JvmName("meters")
public inline val Number.meters: Meters
get() = Meters(value = this.toDouble())
/** Shorthand to create a [Meters] measurement from a [Number] of meters. */
+@get:JvmName("m")
public inline val Number.m: Meters
get() = Meters(value = this.toDouble())
/** Creates a [Meters] measurement from a [Number] of kilometers. */
+@get:JvmName("kilometers")
public inline val Number.kilometers: Meters
get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
/** Shorthand to create a [Meters] measurement from a [Number] of kilometers. */
+@get:JvmName("km")
public inline val Number.km: Meters
get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
/** Creates a [Meters] measurement from a [Number] of international feet. */
+@get:JvmName("feet")
public inline val Number.feet: Meters
get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
/** Shorthand to create a [Meters] measurement from a [Number] of international feet. */
+@get:JvmName("ft")
public inline val Number.ft: Meters
get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
/** Creates a [Meters] measurement from a [Number] of statute miles. */
+@get:JvmName("miles")
public inline val Number.miles: Meters
get() = Meters(value = this.toDouble() * METERS_PER_MILE)
/** Shorthand to create a [Meters] measurement from a [Number] of statute miles. */
+@get:JvmName("mi")
public inline val Number.mi: Meters
get() = Meters(value = this.toDouble() * METERS_PER_MILE)
/** Returns the value of this [Meters] measurement in meters as a [Double]. */
+@get:JvmName("toMeters")
public inline val Meters.toMeters: Double
get() = value
/** Returns the equivalent number of kilometers for this [Meters] measurement. */
+@get:JvmName("toKilometers")
public inline val Meters.toKilometers: Double
get() = value / METERS_PER_KILOMETER
/** Returns the equivalent number of international feet for this [Meters] measurement. */
+@get:JvmName("toFeet")
public inline val Meters.toFeet: Double
get() = value * FEET_PER_METER
/** Returns the equivalent number of statute miles for this [Meters] measurement. */
+@get:JvmName("toMiles")
public inline val Meters.toMiles: Double
get() = value * MILES_PER_METER
// [END maps_android_utils_units_meters]
@@ -613,6 +625,8 @@ public fun formatDistanceValue(value: Double, locale: Locale = Locale.getDefault
* @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
* @return The formatted distance string.
*/
+@JvmName("toDistanceString")
+@JvmOverloads
public fun Meters.toDistanceString(
locale: Locale = Locale.getDefault(),
converter: UnitsConverter = getUnitsConverter(locale),
@@ -626,6 +640,8 @@ public fun Meters.toDistanceString(
* @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
* @return The formatted distance string.
*/
+@JvmName("toDistanceString")
+@JvmOverloads
public fun Meters.toDistanceString(
converter: UnitsConverter,
symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
@@ -638,6 +654,8 @@ public fun Meters.toDistanceString(
* @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
* @return The formatted distance string.
*/
+@JvmName("toDistanceString")
+@JvmOverloads
public fun Meters.toDistanceString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
converter.toDistanceString(resources, this)
@@ -648,6 +666,8 @@ public fun Meters.toDistanceString(resources: Resources, converter: UnitsConvert
* @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [locale]).
* @return The formatted elevation string.
*/
+@JvmName("toElevationString")
+@JvmOverloads
public fun Meters.toElevationString(
locale: Locale = Locale.getDefault(),
converter: UnitsConverter = getUnitsConverter(locale),
@@ -661,6 +681,8 @@ public fun Meters.toElevationString(
* @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
* @return The formatted elevation string.
*/
+@JvmName("toElevationString")
+@JvmOverloads
public fun Meters.toElevationString(
converter: UnitsConverter,
symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(),
@@ -673,6 +695,8 @@ public fun Meters.toElevationString(
* @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
* @return The formatted elevation string.
*/
+@JvmName("toElevationString")
+@JvmOverloads
public fun Meters.toElevationString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
converter.toElevationString(resources, this)
diff --git a/library/src/test/java/com/google/maps/android/UnitsJavaTest.java b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
new file mode 100644
index 000000000..283d77d51
--- /dev/null
+++ b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.maps.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.ft;
+import static com.google.maps.android.Units.kilometers;
+import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.m;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.mi;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+import static com.google.maps.android.Units.toFeet;
+import static com.google.maps.android.Units.toKilometers;
+import static com.google.maps.android.Units.toMeters;
+import static com.google.maps.android.Units.toMiles;
+
+import android.content.Context;
+import android.content.res.Resources;
+import androidx.test.core.app.ApplicationProvider;
+import java.util.Locale;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/**
+ * Verifies the Java interoperability surface for {@link Units}, {@link UnitSystem}, {@link
+ * NumberFormatSymbols}, {@link UnitsConverter}, {@link MetricUnitsConverter}, and {@link
+ * ImperialUnitsConverter}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class UnitsJavaTest {
+
+ @Test
+ public void unitConversions_workIdiomaticallyFromJava() {
+ // [START maps_android_utils_units_java_conversions]
+ // Expressing numbers in meters from Java via static factory methods
+ double twelveFeetInMeters = feet(12);
+ assertThat(twelveFeetInMeters).isWithin(1e-9).of(12 * Units.METERS_PER_FOOT);
+ assertThat(ft(12)).isEqualTo(twelveFeetInMeters);
+ assertThat(toFeet(twelveFeetInMeters)).isWithin(1e-9).of(12.0);
+
+ double twoAndHalfKmInMeters = kilometers(2.5);
+ assertThat(twoAndHalfKmInMeters).isEqualTo(2_500.0);
+ assertThat(km(2.5)).isEqualTo(twoAndHalfKmInMeters);
+ assertThat(toKilometers(twoAndHalfKmInMeters)).isWithin(1e-9).of(2.5);
+
+ double quarterMileInMeters = miles(0.25);
+ assertThat(quarterMileInMeters).isWithin(1e-9).of(1_320 * Units.METERS_PER_FOOT);
+ assertThat(mi(0.25)).isEqualTo(quarterMileInMeters);
+ assertThat(toMiles(quarterMileInMeters)).isWithin(1e-9).of(0.25);
+ assertThat(toFeet(quarterMileInMeters)).isWithin(1e-9).of(1_320.0);
+
+ double hundredMeters = meters(100);
+ assertThat(hundredMeters).isEqualTo(100.0);
+ assertThat(m(100)).isEqualTo(100.0);
+ assertThat(toMeters(hundredMeters)).isEqualTo(100.0);
+ // [END maps_android_utils_units_java_conversions]
+ }
+
+ @Test
+ public void unitSystemAndConverters_resolveFromJava() {
+ assertThat(UnitSystem.fromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL);
+ assertThat(UnitSystem.fromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC);
+ assertThat(UnitSystem.fromCountryCode("GB")).isEqualTo(UnitSystem.IMPERIAL);
+ assertThat(UnitSystem.fromCountryCode("DE")).isEqualTo(UnitSystem.METRIC);
+
+ UnitsConverter imperial = Units.getUnitsConverter(UnitSystem.IMPERIAL);
+ UnitsConverter metric = Units.getUnitsConverter(UnitSystem.METRIC);
+ assertThat(imperial).isSameInstanceAs(ImperialUnitsConverter.INSTANCE);
+ assertThat(metric).isSameInstanceAs(MetricUnitsConverter.INSTANCE);
+ }
+
+ @Test
+ public void distanceAndElevationFormatting_worksFromJava() {
+ UnitsConverter imperial = ImperialUnitsConverter.INSTANCE;
+ UnitsConverter metric = MetricUnitsConverter.INSTANCE;
+
+ // Formatting via converter instance methods
+ assertThat(imperial.toDistanceString(feet(528), Locale.US)).isEqualTo("528 ft");
+ assertThat(imperial.toDistanceString(miles(0.25), Locale.US)).isEqualTo("0.3 mi");
+ assertThat(imperial.toDistanceString(miles(2.0), Locale.US)).isEqualTo("2.0 mi");
+ assertThat(imperial.toDistanceString(miles(12.4), Locale.US)).isEqualTo("12 mi");
+ assertThat(imperial.toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
+
+ assertThat(metric.toDistanceString(meters(450), Locale.US)).isEqualTo("450 m");
+ assertThat(metric.toDistanceString(km(2.0), Locale.US)).isEqualTo("2.0 km");
+ assertThat(metric.toDistanceString(km(12.4), Locale.US)).isEqualTo("12 km");
+ assertThat(metric.toElevationString(meters(4401.2), Locale.GERMANY)).isEqualTo("4.401 m");
+
+ // Formatting via top-level Units static methods
+ assertThat(toDistanceString(km(2.0), Locale.US)).isEqualTo("1.2 mi");
+ assertThat(toDistanceString(km(2.0), Locale.FRANCE)).isEqualTo("2,0 km");
+ assertThat(toDistanceString(km(2.0), metric, NumberFormatSymbols.DEFAULT)).isEqualTo("2.0 km");
+ assertThat(toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
+ assertThat(toElevationString(meters(4401.2), metric, NumberFormatSymbols.DEFAULT))
+ .isEqualTo("4,401 m");
+
+ // Formatting with Android Resources from Java
+ Context context = ApplicationProvider.getApplicationContext();
+ Resources resources = context.getResources();
+ assertThat(toDistanceString(km(2.0), resources, metric)).isEqualTo("2.0 km");
+ assertThat(toElevationString(meters(4401.2), resources, imperial)).isEqualTo("14,440 ft");
+
+ // Standalone logical number formatting
+ assertThat(Units.formatDistanceValue(2.0, Locale.US)).isEqualTo("2.0");
+ assertThat(Units.formatDistanceValue(1250.4, NumberFormatSymbols.fromLocale(Locale.GERMANY)))
+ .isEqualTo("1.250");
+ }
+}
From 113f2479a40680a90dcd03e94754cd7f4633fccb Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Mon, 26 Oct 2026 10:46:51 -0600
Subject: [PATCH 4/5] docs(skill): document Units API with Kotlin and Java
examples in SKILL.md
---
.gemini/skills/android-maps-utils/SKILL.md | 73 +++++++++++++++++++-
llm-integration-prompt.md | 77 ++++++++++++++++++++--
2 files changed, 143 insertions(+), 7 deletions(-)
diff --git a/.gemini/skills/android-maps-utils/SKILL.md b/.gemini/skills/android-maps-utils/SKILL.md
index 38981aed1..bc7d4fb30 100644
--- a/.gemini/skills/android-maps-utils/SKILL.md
+++ b/.gemini/skills/android-maps-utils/SKILL.md
@@ -1,6 +1,6 @@
---
name: maps-utils-android
-description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, or Polyline encoding/decoding.
+description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, Polyline encoding/decoding, or Units (distance and elevation conversion and formatting).
---
# Google Maps Utility Library for Android Integration
@@ -45,6 +45,77 @@ Used for server-client coordinate compression and distance calculations.
* **Decoding:** `PolyUtil.decode(encodedPathString)`
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
+### Units & Logical Distance/Elevation Formatting
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` (`US`, `LR`, `MM`, and `GB` default to `IMPERIAL`; all others default to `METRIC`) or an explicit `UnitSystem` override.
+
+* **Logical Precision Rules:**
+ * **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
+ * **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+ * **Large distances** (`>= 10` km or `>= 10` mi) round to whole integers (`"10 km"`, `"12 mi"`).
+
+* **Kotlin Example:**
+```kotlin
+import com.google.maps.android.Meters
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.feet
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.km
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import com.google.maps.android.toFeet
+
+// Construct type-safe Meters and perform arithmetic or unit conversions
+val summitElevation: Meters = 4401.2.meters
+val elevationInFeet: Double = summitElevation.toFeet // 14439.6325...
+val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
+
+// Format using the device's default Locale (no Android Context required)
+val defaultDistance: String = routeDistance.toDistanceString()
+val defaultElevation: String = summitElevation.toElevationString()
+
+// Or resolve an explicit Metric / Imperial converter
+val metric = getUnitsConverter(UnitSystem.METRIC)
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+```
+
+* **Java Example:**
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+import static com.google.maps.android.Units.toFeet;
+
+import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+
+// Express measurements in meters and convert between units
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+
+// Format distances and elevations using device default or explicit UnitSystem
+double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
+String defaultFormatted = toDistanceString(distanceMeters);
+
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(km(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+```
+
## 3. Best Practices
1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread.
2. **Custom Renderers:** If the user wants custom cluster icons, extend `DefaultClusterRenderer` and override `onBeforeClusterItemRendered` and `onBeforeClusterRendered`.
diff --git a/llm-integration-prompt.md b/llm-integration-prompt.md
index 05564febd..5dbccd107 100644
--- a/llm-integration-prompt.md
+++ b/llm-integration-prompt.md
@@ -1,6 +1,6 @@
---
name: maps-utils-android
-description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, or Polyline encoding/decoding.
+description: Guide for integrating the Google Maps Utility Library for Android (android-maps-utils) into an application. Use when users ask to add features like Marker Clustering, Heatmaps, GeoJSON, KML, Polyline encoding/decoding, or Units (distance and elevation conversion and formatting).
---
# Google Maps Utility Library for Android Integration
@@ -46,11 +46,76 @@ Used for server-client coordinate compression and distance calculations.
* **Decoding:** `PolyUtil.decode(encodedPathString)`
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
-### Units & Distance/Elevation Formatting
-Used to convert and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or an explicit `UnitSystem`.
-* **Construct `Meters`:** `450.meters`, `2.0.km`, `0.25.miles`, `14440.feet`
-* **Resolve converter:** `val converter = getUnitsConverter()` (defaults to `Locale.getDefault()`), `getUnitsConverter(UnitSystem.IMPERIAL)`, or `getUnitsConverter("US")`
-* **Format strings:** `distance.toDistanceString(converter)` and `elevation.toElevationString(converter)`
+### Units & Logical Distance/Elevation Formatting
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` (`US`, `LR`, `MM`, and `GB` default to `IMPERIAL`; all others default to `METRIC`) or an explicit `UnitSystem` override.
+
+* **Logical Precision Rules:**
+ * **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
+ * **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`"0.3 mi"`, `"1.0 km"`, `"2.5 mi"`).
+ * **Large distances** (`>= 10` km or `>= 10` mi) round to whole integers (`"10 km"`, `"12 mi"`).
+
+* **Kotlin Example:**
+```kotlin
+import com.google.maps.android.Meters
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.feet
+import com.google.maps.android.getUnitsConverter
+import com.google.maps.android.km
+import com.google.maps.android.meters
+import com.google.maps.android.miles
+import com.google.maps.android.sphericalDistance
+import com.google.maps.android.toDistanceString
+import com.google.maps.android.toElevationString
+import com.google.maps.android.toFeet
+
+// Construct type-safe Meters and perform arithmetic or unit conversions
+val summitElevation: Meters = 4401.2.meters
+val elevationInFeet: Double = summitElevation.toFeet // 14439.6325...
+val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
+
+// Format using the device's default Locale (no Android Context required)
+val defaultDistance: String = routeDistance.toDistanceString()
+val defaultElevation: String = summitElevation.toElevationString()
+
+// Or resolve an explicit Metric / Imperial converter
+val metric = getUnitsConverter(UnitSystem.METRIC)
+val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
+
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+```
+
+* **Java Example:**
+```java
+import static com.google.maps.android.Units.feet;
+import static com.google.maps.android.Units.getUnitsConverter;
+import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.meters;
+import static com.google.maps.android.Units.miles;
+import static com.google.maps.android.Units.toDistanceString;
+import static com.google.maps.android.Units.toElevationString;
+import static com.google.maps.android.Units.toFeet;
+
+import com.google.maps.android.SphericalUtil;
+import com.google.maps.android.UnitSystem;
+import com.google.maps.android.UnitsConverter;
+
+// Express measurements in meters and convert between units
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+
+// Format distances and elevations using device default or explicit UnitSystem
+double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
+String defaultFormatted = toDistanceString(distanceMeters);
+
+UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(km(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+```
## 3. Best Practices
1. **Performance:** For massive datasets (e.g., parsing huge GeoJSON files), do not block the main thread. Parse on a background dispatcher and only call `layer.addLayerToMap()` on the UI thread.
From dcd1df05647c440c399cf305c6672a262ea5962e Mon Sep 17 00:00:00 2001
From: Dale Hawkins <107309+dkhawk@users.noreply.github.com>
Date: Wed, 28 Oct 2026 17:21:38 -0600
Subject: [PATCH 5/5] feat(units): address PR #1832 review feedback and support
hybrid GB units
- Default `groupingSize` to `0` in `NumberFormatSymbols.fromLocale` when `!decimalFormat.isGroupingUsed`
- Fix threshold boundary rounding in `MetricUnitsConverter` (`999.6 m` -> `"1.0 km"`) and `ImperialUnitsConverter` (`1,319.6 ft` -> `"0.3 mi"`)
- Replace `translatable="false"` numeric templates with 4 translatable `%1$s` unit templates (`amu_in_feet`, `amu_in_miles`, `amu_in_meters`, `amu_in_kilometers`), add 24 localized `values-/strings.xml` translations, and unify `Locale` and `Resources` formatting onto a single `NumberFormatSymbols` code path
- Add `@get:JvmName("getMilesThreshold")` and `@get:JvmName("getKilometersThreshold")` for Java interop
- Remove short `Number.m`, `.km`, `.ft`, and `.mi` extensions and rename `Meters.to*` properties to `inMeters`, `inKilometers`, `inFeet`, and `inMiles`
- Default `"GB"` (`Locale.UK`) out-of-the-box to Imperial road distances (`ft`/`mi`) + Metric elevations (`m`), and add `withDistanceUnits`, `withElevationUnits`, `withDistanceConverter`, `withElevationConverter`, `withDistanceFormatter`, `withElevationFormatter`, and `UnitsConverter(...)` / `UnitsConverter.create(...)` overloads
- Update `README.md`, `llm-integration-prompt.md`, `.gemini/skills/android-maps-utils/SKILL.md`, and unit tests (`UnitsTest`, `UnitsJavaTest`, `UnitsDemoTest`)
---
.gemini/skills/android-maps-utils/SKILL.md | 91 +-
README.md | 103 ++-
.../android/utils/demo/UnitsDemoActivity.kt | 46 +-
.../maps/android/utils/demo/UnitsDemoTest.kt | 14 +-
library/api/library.api | 125 ++-
.../java/com/google/maps/android/Units.kt | 856 ++++++++++++++----
library/src/main/res/values-ar/strings.xml | 22 +
library/src/main/res/values-bg/strings.xml | 22 +
library/src/main/res/values-de/strings.xml | 22 +
library/src/main/res/values-el/strings.xml | 22 +
library/src/main/res/values-es/strings.xml | 22 +
library/src/main/res/values-fa/strings.xml | 22 +
library/src/main/res/values-fr/strings.xml | 22 +
library/src/main/res/values-hi/strings.xml | 22 +
library/src/main/res/values-in/strings.xml | 22 +
library/src/main/res/values-it/strings.xml | 22 +
library/src/main/res/values-iw/strings.xml | 22 +
library/src/main/res/values-ja/strings.xml | 22 +
library/src/main/res/values-ko/strings.xml | 22 +
library/src/main/res/values-nl/strings.xml | 22 +
library/src/main/res/values-pl/strings.xml | 22 +
library/src/main/res/values-pt/strings.xml | 22 +
library/src/main/res/values-ru/strings.xml | 22 +
library/src/main/res/values-th/strings.xml | 22 +
library/src/main/res/values-tr/strings.xml | 22 +
library/src/main/res/values-uk/strings.xml | 22 +
library/src/main/res/values-vi/strings.xml | 22 +
.../src/main/res/values-zh-rCN/strings.xml | 22 +
.../src/main/res/values-zh-rHK/strings.xml | 22 +
.../src/main/res/values-zh-rTW/strings.xml | 22 +
library/src/main/res/values/strings.xml | 34 +-
.../google/maps/android/UnitsJavaTest.java | 106 ++-
.../java/com/google/maps/android/UnitsTest.kt | 356 ++++++--
llm-integration-prompt.md | 91 +-
34 files changed, 1893 insertions(+), 457 deletions(-)
create mode 100644 library/src/main/res/values-ar/strings.xml
create mode 100644 library/src/main/res/values-bg/strings.xml
create mode 100644 library/src/main/res/values-de/strings.xml
create mode 100644 library/src/main/res/values-el/strings.xml
create mode 100644 library/src/main/res/values-es/strings.xml
create mode 100644 library/src/main/res/values-fa/strings.xml
create mode 100644 library/src/main/res/values-fr/strings.xml
create mode 100644 library/src/main/res/values-hi/strings.xml
create mode 100644 library/src/main/res/values-in/strings.xml
create mode 100644 library/src/main/res/values-it/strings.xml
create mode 100644 library/src/main/res/values-iw/strings.xml
create mode 100644 library/src/main/res/values-ja/strings.xml
create mode 100644 library/src/main/res/values-ko/strings.xml
create mode 100644 library/src/main/res/values-nl/strings.xml
create mode 100644 library/src/main/res/values-pl/strings.xml
create mode 100644 library/src/main/res/values-pt/strings.xml
create mode 100644 library/src/main/res/values-ru/strings.xml
create mode 100644 library/src/main/res/values-th/strings.xml
create mode 100644 library/src/main/res/values-tr/strings.xml
create mode 100644 library/src/main/res/values-uk/strings.xml
create mode 100644 library/src/main/res/values-vi/strings.xml
create mode 100644 library/src/main/res/values-zh-rCN/strings.xml
create mode 100644 library/src/main/res/values-zh-rHK/strings.xml
create mode 100644 library/src/main/res/values-zh-rTW/strings.xml
diff --git a/.gemini/skills/android-maps-utils/SKILL.md b/.gemini/skills/android-maps-utils/SKILL.md
index bc7d4fb30..9c072b47f 100644
--- a/.gemini/skills/android-maps-utils/SKILL.md
+++ b/.gemini/skills/android-maps-utils/SKILL.md
@@ -46,7 +46,7 @@ Used for server-client coordinate compression and distance calculations.
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
### Units & Logical Distance/Elevation Formatting
-Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` (`US`, `LR`, `MM`, and `GB` default to `IMPERIAL`; all others default to `METRIC`) or an explicit `UnitSystem` override.
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or `Resources` (`US`, `LR`, and `MM` default to `IMPERIAL` for both; `GB` defaults to `IMPERIAL` road distances + `METRIC` elevations; all others default to `METRIC`), or an explicit `UnitSystem` / formatter override.
* **Logical Precision Rules:**
* **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
@@ -55,65 +55,116 @@ Used to represent measurements with the type-safe `Meters` inline value class an
* **Kotlin Example:**
```kotlin
+import com.google.maps.android.ImperialUnitsConverter
import com.google.maps.android.Meters
+import com.google.maps.android.MetricUnitsConverter
import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
import com.google.maps.android.feet
import com.google.maps.android.getUnitsConverter
-import com.google.maps.android.km
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
import com.google.maps.android.meters
import com.google.maps.android.miles
import com.google.maps.android.sphericalDistance
import com.google.maps.android.toDistanceString
import com.google.maps.android.toElevationString
-import com.google.maps.android.toFeet
+import java.util.Locale
// Construct type-safe Meters and perform arithmetic or unit conversions
val summitElevation: Meters = 4401.2.meters
-val elevationInFeet: Double = summitElevation.toFeet // 14439.6325...
+val elevationInFeet: Double = summitElevation.inFeet // 14439.6325...
val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
-// Format using the device's default Locale (no Android Context required)
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources
val defaultDistance: String = routeDistance.toDistanceString()
-val defaultElevation: String = summitElevation.toElevationString()
+val localizedDistance: String = routeDistance.toDistanceString(context.resources)
+val ukDistance: String = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation: String = 1345.meters.toElevationString(Locale.UK) // "1,345 m"
-// Or resolve an explicit Metric / Imperial converter
+// 2. Override both distance and elevation to Metric or Imperial
val metric = getUnitsConverter(UnitSystem.METRIC)
val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
-val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
-val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
-val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
-val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
-val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
```
* **Java Example:**
```java
import static com.google.maps.android.Units.feet;
import static com.google.maps.android.Units.getUnitsConverter;
-import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
import static com.google.maps.android.Units.meters;
import static com.google.maps.android.Units.miles;
import static com.google.maps.android.Units.toDistanceString;
import static com.google.maps.android.Units.toElevationString;
-import static com.google.maps.android.Units.toFeet;
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
import com.google.maps.android.SphericalUtil;
import com.google.maps.android.UnitSystem;
import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
// Express measurements in meters and convert between units
-double twelveFeetInMeters = feet(12); // 3.6576 m
-double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
-// Format distances and elevations using device default or explicit UnitSystem
+// 1. Default formatting (Locale or Android Resources)
double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
String defaultFormatted = toDistanceString(distanceMeters);
+String localizedFormatted = toDistanceString(distanceMeters, context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+// 2. Override both distance and elevation
UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
-String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
-String mediumDrive = imperial.toDistanceString(km(2.0)); // "1.2 mi"
-String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(kilometers(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
```
## 3. Best Practices
diff --git a/README.md b/README.md
index 19254ab65..124531c38 100644
--- a/README.md
+++ b/README.md
@@ -174,36 +174,67 @@ Full guides for using the utilities are published in
### Units & Logical Distance/Elevation Formatting
-The `Units` API (`com.google.maps.android`) provides a type-safe `Meters` value class, framework-agnostic `UnitSystem` resolution (defaulting to Imperial for `US`, `LR`, `MM`, and `GB` locales, and Metric elsewhere), and logical significant-digit formatting for distances and elevations:
+The `Units` API (`com.google.maps.android`) provides a type-safe `Meters` value class, locale- and `Resources`-aware `UnitSystem` resolution, and logical significant-digit formatting for distances and elevations:
-- **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers (`450 m`, `528 ft`, `14,440 ft`).
-- **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`0.3 mi`, `1.0 km`, `2.5 mi`).
-- **Large distances** (`>= 10` km or `>= 10` mi) round to whole numbers (`10 km`, `12 mi`).
+- **Locale Defaults**:
+ - `US`, `LR`, and `MM` default to **Imperial** (`ft`, `mi`) for both road distances and elevations.
+ - `GB` (`Locale.UK`) defaults out-of-the-box to **Imperial road distances** (`ft`, `mi`) and **Metric elevations** (`m`).
+ - All other locales default to **Metric** (`m`, `km`) for both road distances and elevations.
+- **Logical Precision Rules**:
+ - **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers (`450 m`, `528 ft`, `14,440 ft`).
+ - **Medium distances** (`1.0` to `< 10` km, or `0.25` to `< 10` mi) format with 1 decimal digit, keeping trailing `.0` (`0.3 mi`, `1.0 km`, `2.5 mi`).
+ - **Large distances** (`>= 10` km or `>= 10` mi) round to whole numbers (`10 km`, `12 mi`).
```kotlin
import com.google.maps.android.ImperialUnitsConverter
import com.google.maps.android.MetricUnitsConverter
import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
import com.google.maps.android.getUnitsConverter
-import com.google.maps.android.km
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
import com.google.maps.android.meters
import com.google.maps.android.miles
import com.google.maps.android.toDistanceString
import com.google.maps.android.toElevationString
+import java.util.Locale
-// Default converter for the device's current Locale (no Android Context required)
-val converter = getUnitsConverter()
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources (for localized unit templates)
+val distanceText = 450.meters.toDistanceString() // Uses Locale.getDefault()
+val localizedText = 450.meters.toDistanceString(context.resources) // Uses localized R.string.amu_in_* templates
+val ukDistance = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation = 1345.meters.toElevationString(Locale.UK) // "1,345 m" (GB defaults to miles/ft + metres)
-// Or explicitly choose a unit system / country code / Locale
+// 2. Override both distance and elevation to Metric or Imperial
val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
-val metric = getUnitsConverter("FR")
-
-// Format distances and elevations
-val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
-val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
-val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
-val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
-val summit = 4401.2.meters.toElevationString(imperial) // "14,440 ft"
+val metric = getUnitsConverter(UnitSystem.METRIC)
+
+val summitFeet = 4401.2.meters.inFeet // 14439.6325...
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = 4401.2.meters.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation (via factory parameters, fluent copy methods, or UnitSystem pair)
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
```
In Java, `Units` exposes static conversion and formatting methods (usable with `import static com.google.maps.android.Units.*;`):
@@ -211,22 +242,50 @@ In Java, `Units` exposes static conversion and formatting methods (usable with `
```java
import static com.google.maps.android.Units.feet;
import static com.google.maps.android.Units.getUnitsConverter;
-import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
import static com.google.maps.android.Units.meters;
import static com.google.maps.android.Units.miles;
import static com.google.maps.android.Units.toDistanceString;
import static com.google.maps.android.Units.toElevationString;
-import static com.google.maps.android.Units.toFeet;
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
import com.google.maps.android.UnitSystem;
import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
+
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
-double twelveFeetInMeters = feet(12); // 3.6576 m
-double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+// 1. Default formatting (Locale or Android Resources)
+String defaultDistance = toDistanceString(miles(2.5)); // "2.5 mi" (in US/GB locale)
+String localizedDistance = toDistanceString(miles(2.5), context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+// 2. Override both distance and elevation
UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
-String distance = imperial.toDistanceString(miles(2.5)); // "2.5 mi"
-String summit = toElevationString(meters(4401.2)); // "14,440 ft" (in US locale)
+String distance = imperial.toDistanceString(miles(2.5)); // "2.5 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
```
diff --git a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
index 061527940..012a254d7 100644
--- a/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
+++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt
@@ -35,10 +35,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalLocale
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.toColorInt
+import androidx.core.view.doOnLayout
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
@@ -49,7 +51,7 @@ import com.google.maps.android.Meters
import com.google.maps.android.UnitSystem
import com.google.maps.android.UnitsConverter
import com.google.maps.android.getUnitsConverter
-import com.google.maps.android.km
+import com.google.maps.android.kilometers
import com.google.maps.android.meters
import com.google.maps.android.miles
import com.google.maps.android.sphericalDistance
@@ -82,11 +84,32 @@ class UnitsDemoActivity : BaseDemoActivity() {
IMPERIAL,
;
+ fun resolveDistanceUnits(locale: Locale = Locale.getDefault()): UnitSystem = when (this) {
+ DEVICE_DEFAULT -> UnitSystem.distanceFromLocale(locale)
+ METRIC -> UnitSystem.METRIC
+ IMPERIAL -> UnitSystem.IMPERIAL
+ }
+
+ fun resolveElevationUnits(locale: Locale = Locale.getDefault()): UnitSystem = when (this) {
+ DEVICE_DEFAULT -> UnitSystem.elevationFromLocale(locale)
+ METRIC -> UnitSystem.METRIC
+ IMPERIAL -> UnitSystem.IMPERIAL
+ }
+
fun resolveConverter(locale: Locale = Locale.getDefault()): UnitsConverter = when (this) {
DEVICE_DEFAULT -> getUnitsConverter(locale)
METRIC -> getUnitsConverter(UnitSystem.METRIC)
IMPERIAL -> getUnitsConverter(UnitSystem.IMPERIAL)
}
+ fun resolveSystemSummary(locale: Locale = Locale.getDefault()): String {
+ val distance = resolveDistanceUnits(locale)
+ val elevation = resolveElevationUnits(locale)
+ return if (distance == elevation) {
+ distance.name
+ } else {
+ "${distance.name} / ${elevation.name}"
+ }
+ }
}
private val peaks = listOf(
@@ -99,9 +122,9 @@ class UnitsDemoActivity : BaseDemoActivity() {
private val sampleDistances = listOf(
"Short (< 0.25 mi)" to 150.meters,
"Quarter mile (0.25 mi)" to 0.25.miles,
- "Medium (2.0 km)" to 2.0.km,
+ "Medium (2.0 km)" to 2.0.kilometers,
"Multi-mile (2.5 mi)" to 2.5.miles,
- "Long (>= 10 km)" to 12.4.km,
+ "Long (>= 10 km)" to 12.4.kilometers,
)
// [END maps_android_utils_units_demo_model]
@@ -156,7 +179,9 @@ class UnitsDemoActivity : BaseDemoActivity() {
currentMap.setPadding(0, 0, 0, heightPx)
if (!hasCenteredCamera) {
hasCenteredCamera = true
- currentMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 140))
+ findViewById(R.id.map).doOnLayout {
+ currentMap.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 140))
+ }
}
}
},
@@ -181,6 +206,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
onPanelHeightChanged: (Int) -> Unit = {},
) {
val currentLocale = LocalLocale.current.platformLocale
+ val currentResources = LocalResources.current
// [START maps_android_utils_units_demo_formatting]
val converter = preference.resolveConverter(currentLocale)
val firstLegDistance: Meters = peaks[0].position.sphericalDistance(peaks[1].position).meters
@@ -207,7 +233,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
Text(
text = stringResource(
R.string.units_demo_active_system,
- converter.unitSystem.name,
+ preference.resolveSystemSummary(currentLocale),
currentLocale.country.ifEmpty { "default" },
),
style = MaterialTheme.typography.bodySmall,
@@ -236,19 +262,19 @@ class UnitsDemoActivity : BaseDemoActivity() {
SummaryRow(
label = "${peaks[0].name} Elevation",
- value = peaks[0].elevation.toElevationString(currentLocale, converter),
+ value = peaks[0].elevation.toElevationString(currentResources, converter),
)
SummaryRow(
label = "Elevation Delta (${peaks[0].name} → ${peaks.last().name})",
- value = elevationDifference.toElevationString(currentLocale, converter),
+ value = elevationDifference.toElevationString(currentResources, converter),
)
SummaryRow(
label = "Leg 1 (${peaks[0].name} → ${peaks[1].name})",
- value = firstLegDistance.toDistanceString(currentLocale, converter),
+ value = firstLegDistance.toDistanceString(currentResources, converter),
)
SummaryRow(
label = "Total Geodesic Route",
- value = totalRouteDistance.toDistanceString(currentLocale, converter),
+ value = totalRouteDistance.toDistanceString(currentResources, converter),
)
HorizontalDivider()
@@ -261,7 +287,7 @@ class UnitsDemoActivity : BaseDemoActivity() {
for ((label, distance) in sampleDistances) {
SummaryRow(
label = label,
- value = distance.toDistanceString(currentLocale, converter),
+ value = distance.toDistanceString(currentResources, converter),
)
}
}
diff --git a/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
index 7becd7eda..2e2cc164a 100644
--- a/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
+++ b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt
@@ -18,6 +18,8 @@ package com.google.maps.android.utils.demo
import com.google.common.truth.Truth.assertThat
import com.google.maps.android.ImperialUnitsConverter
import com.google.maps.android.MetricUnitsConverter
+import com.google.maps.android.UnitSystem
+import com.google.maps.android.meters
import com.google.maps.android.utils.demo.UnitsDemoActivity.UnitPreference
import org.junit.Test
import java.util.Locale
@@ -27,8 +29,16 @@ class UnitsDemoTest {
fun unitPreference_resolvesExpectedConverter() {
assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.US))
.isSameInstanceAs(ImperialUnitsConverter)
- assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.UK))
- .isSameInstanceAs(ImperialUnitsConverter)
+
+ // UK (GB) defaults to Imperial road distances (ft/mi) + Metric elevations (m)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveDistanceUnits(Locale.UK))
+ .isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitPreference.DEVICE_DEFAULT.resolveElevationUnits(Locale.UK))
+ .isEqualTo(UnitSystem.METRIC)
+ val ukConverter = UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.UK)
+ assertThat(ukConverter.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukConverter.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
assertThat(UnitPreference.DEVICE_DEFAULT.resolveConverter(Locale.FRANCE))
.isSameInstanceAs(MetricUnitsConverter)
diff --git a/library/api/library.api b/library/api/library.api
index de63f9083..9cd30af43 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -112,10 +112,12 @@ public final class com/google/maps/android/GoogleMapKt {
public final class com/google/maps/android/ImperialUnitsConverter : com/google/maps/android/UnitsConverter {
public static final field INSTANCE Lcom/google/maps/android/ImperialUnitsConverter;
- public final fun getMILES_THRESHOLD-NA-LpbA ()D
- public fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
- public fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public final fun getMilesThreshold ()D
+ public final fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
}
public final class com/google/maps/android/IndoorBuildingFocusedEvent : com/google/maps/android/IndoorChangeEvent {
@@ -223,6 +225,10 @@ public final class com/google/maps/android/MathUtil {
public static final fun wrap (DDD)D
}
+public abstract interface class com/google/maps/android/MeasurementFormatter {
+ public abstract fun format (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+}
+
public final class com/google/maps/android/Meters : java/lang/Comparable {
public static final synthetic fun box-impl (D)Lcom/google/maps/android/Meters;
public synthetic fun compareTo (Ljava/lang/Object;)I
@@ -248,10 +254,12 @@ public final class com/google/maps/android/Meters : java/lang/Comparable {
public final class com/google/maps/android/MetricUnitsConverter : com/google/maps/android/UnitsConverter {
public static final field INSTANCE Lcom/google/maps/android/MetricUnitsConverter;
- public final fun getKILOMETERS_THRESHOLD-NA-LpbA ()D
- public fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
- public fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
+ public final fun getKilometersThreshold ()D
+ public final fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
}
public abstract interface annotation class com/google/maps/android/MoveStartedReason : java/lang/annotation/Annotation {
@@ -269,6 +277,10 @@ public final class com/google/maps/android/NumberFormatSymbols {
public final fun copy (CCI)Lcom/google/maps/android/NumberFormatSymbols;
public static synthetic fun copy$default (Lcom/google/maps/android/NumberFormatSymbols;CCIILjava/lang/Object;)Lcom/google/maps/android/NumberFormatSymbols;
public fun equals (Ljava/lang/Object;)Z
+ public final fun formatDistanceValue (D)Ljava/lang/String;
+ public final fun formatFixedDecimal (D)Ljava/lang/String;
+ public final fun formatFixedDecimal (DI)Ljava/lang/String;
+ public static synthetic fun formatFixedDecimal$default (Lcom/google/maps/android/NumberFormatSymbols;DIILjava/lang/Object;)Ljava/lang/String;
public static final fun fromLocale ()Lcom/google/maps/android/NumberFormatSymbols;
public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/NumberFormatSymbols;
public final fun getDecimalSeparator ()C
@@ -327,10 +339,8 @@ public final class com/google/maps/android/PolylineKt {
public final class com/google/maps/android/R$string {
public static field amu_in_feet I
public static field amu_in_kilometers I
- public static field amu_in_kilometers_whole I
public static field amu_in_meters I
public static field amu_in_miles I
- public static field amu_in_miles_whole I
}
public final class com/google/maps/android/ResponseStreetView {
@@ -442,9 +452,18 @@ public final class com/google/maps/android/UnitSystem : java/lang/Enum {
public static final field Companion Lcom/google/maps/android/UnitSystem$Companion;
public static final field IMPERIAL Lcom/google/maps/android/UnitSystem;
public static final field METRIC Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun distanceFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun elevationFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
public static final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
public static final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
public static final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static final fun fromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
public final fun getConverter ()Lcom/google/maps/android/UnitsConverter;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
@@ -452,11 +471,23 @@ public final class com/google/maps/android/UnitSystem : java/lang/Enum {
}
public final class com/google/maps/android/UnitSystem$Companion {
+ public final fun distanceFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun distanceFromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun distanceFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromLocale ()Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
+ public static synthetic fun elevationFromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun elevationFromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
public final fun fromCountryCode (Ljava/lang/String;)Lcom/google/maps/android/UnitSystem;
public final fun fromLocale ()Lcom/google/maps/android/UnitSystem;
public final fun fromLocale (Ljava/util/Locale;)Lcom/google/maps/android/UnitSystem;
public static synthetic fun fromLocale$default (Lcom/google/maps/android/UnitSystem$Companion;Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitSystem;
+ public final fun fromResources (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitSystem;
public final fun getIMPERIAL_COUNTRY_CODES ()Ljava/util/Set;
+ public final fun getIMPERIAL_ELEVATION_COUNTRY_CODES ()Ljava/util/Set;
}
public final class com/google/maps/android/Units {
@@ -466,22 +497,36 @@ public final class com/google/maps/android/Units {
public static final field METERS_PER_KILOMETER I
public static final field METERS_PER_MILE D
public static final field MILES_PER_METER D
+ public static final fun UnitsConverter (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun UnitsConverter (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lcom/google/maps/android/UnitsConverter;Lkotlin/jvm/functions/Function2;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lkotlin/jvm/functions/Function2;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final synthetic fun UnitsConverter (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lcom/google/maps/android/UnitsConverter;
public static final fun feet (Ljava/lang/Number;)D
public static final fun formatDistanceValue (D)Ljava/lang/String;
public static final fun formatDistanceValue (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public static final fun formatDistanceValue (DLjava/util/Locale;)Ljava/lang/String;
public static synthetic fun formatDistanceValue$default (DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public static final fun ft (Ljava/lang/Number;)D
public static final fun getUnitsConverter ()Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Ljava/lang/String;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
public static final fun getUnitsConverter (Ljava/util/Locale;)Lcom/google/maps/android/UnitsConverter;
- public static synthetic fun getUnitsConverter$default (Ljava/util/Locale;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun getUnitsConverter (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Landroid/content/res/Resources;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Ljava/lang/String;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static synthetic fun getUnitsConverter$default (Ljava/util/Locale;Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;ILjava/lang/Object;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun inFeet (D)D
+ public static final fun inKilometers (D)D
+ public static final fun inMeters (D)D
+ public static final fun inMiles (D)D
public static final fun kilometers (Ljava/lang/Number;)D
- public static final fun km (Ljava/lang/Number;)D
- public static final fun m (Ljava/lang/Number;)D
public static final fun meters (Ljava/lang/Number;)D
- public static final fun mi (Ljava/lang/Number;)D
public static final fun miles (Ljava/lang/Number;)D
public static final fun toDistanceString (D)Ljava/lang/String;
public static final fun toDistanceString (DLandroid/content/res/Resources;)Ljava/lang/String;
@@ -503,50 +548,48 @@ public final class com/google/maps/android/Units {
public static synthetic fun toElevationString$default (DLandroid/content/res/Resources;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
public static synthetic fun toElevationString$default (DLcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/NumberFormatSymbols;ILjava/lang/Object;)Ljava/lang/String;
public static synthetic fun toElevationString$default (DLjava/util/Locale;Lcom/google/maps/android/UnitsConverter;ILjava/lang/Object;)Ljava/lang/String;
- public static final fun toFeet (D)D
- public static final fun toKilometers (D)D
- public static final fun toMeters (D)D
- public static final fun toMiles (D)D
}
public abstract class com/google/maps/android/UnitsConverter {
+ public static final field Companion Lcom/google/maps/android/UnitsConverter$Companion;
public fun ()V
- public abstract fun getUnitSystem ()Lcom/google/maps/android/UnitSystem;
+ public static final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public static final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
public final fun toDistanceString (D)Ljava/lang/String;
public final fun toDistanceString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public final fun toDistanceString (DLjava/util/Locale;)Ljava/lang/String;
public final fun toDistanceString (Landroid/content/res/Resources;D)Ljava/lang/String;
public static synthetic fun toDistanceString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public final fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
- public final fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toDistanceString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public abstract fun toDistanceString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public final fun toDistanceString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
public static synthetic fun toDistanceString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public final fun toDistanceUnits (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public abstract fun toDistanceUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
public final fun toElevationString (D)Ljava/lang/String;
public final fun toElevationString (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public final fun toElevationString (DLjava/util/Locale;)Ljava/lang/String;
public final fun toElevationString (Landroid/content/res/Resources;D)Ljava/lang/String;
public static synthetic fun toElevationString$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public final fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
- public final fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
+ public fun toElevationString-o-ZRCEM (Landroid/content/res/Resources;D)Ljava/lang/String;
+ public abstract fun toElevationString-s1HjgZ8 (DLcom/google/maps/android/NumberFormatSymbols;)Ljava/lang/String;
public final fun toElevationString-s1HjgZ8 (DLjava/util/Locale;)Ljava/lang/String;
public static synthetic fun toElevationString-s1HjgZ8$default (Lcom/google/maps/android/UnitsConverter;DLjava/util/Locale;ILjava/lang/Object;)Ljava/lang/String;
- public final fun toElevationUnits (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public abstract fun toElevationUnits-5-sh_3o (D)Lcom/google/maps/android/ValueWithUnitsTemplate;
-}
-
-public final class com/google/maps/android/ValueWithUnitsTemplate {
- public fun (DI)V
- public final fun component1 ()D
- public final fun component2 ()I
- public final fun copy (DI)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public static synthetic fun copy$default (Lcom/google/maps/android/ValueWithUnitsTemplate;DIILjava/lang/Object;)Lcom/google/maps/android/ValueWithUnitsTemplate;
- public fun equals (Ljava/lang/Object;)Z
- public final fun getUnitsTemplate ()I
- public final fun getValue ()D
- public fun hashCode ()I
- public fun toString ()Ljava/lang/String;
+ public final fun withDistanceConverter (Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withDistanceFormatter (Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withDistanceUnits (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationConverter (Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationFormatter (Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun withElevationUnits (Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+}
+
+public final class com/google/maps/android/UnitsConverter$Companion {
+ public final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/MeasurementFormatter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitSystem;Lcom/google/maps/android/UnitSystem;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/MeasurementFormatter;)Lcom/google/maps/android/UnitsConverter;
+ public final fun create (Lcom/google/maps/android/UnitsConverter;Lcom/google/maps/android/UnitsConverter;)Lcom/google/maps/android/UnitsConverter;
}
public class com/google/maps/android/collections/CircleManager : com/google/maps/android/collections/MapObjectManager, com/google/android/gms/maps/GoogleMap$OnCircleClickListener {
diff --git a/library/src/main/java/com/google/maps/android/Units.kt b/library/src/main/java/com/google/maps/android/Units.kt
index 5c7c9ae5f..509a8c66c 100644
--- a/library/src/main/java/com/google/maps/android/Units.kt
+++ b/library/src/main/java/com/google/maps/android/Units.kt
@@ -18,7 +18,7 @@
package com.google.maps.android
import android.content.res.Resources
-import androidx.annotation.StringRes
+import androidx.core.os.ConfigurationCompat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
@@ -33,7 +33,7 @@ import kotlin.math.roundToLong
// By international agreement (1959 International Yard and Pound Agreement), one international foot
// is defined as exactly 0.3048 meters (12 inches * 2.54 cm/inch / 100 cm/meter).
// All derived imperial-to-metric and metric-to-imperial conversion constants below are grounded in
-// this exact definition so that round-trip conversions (e.g., `1.miles.toMiles`, `5280.feet.toMiles`)
+// this exact definition so that round-trip conversions (e.g., `1.miles.inMiles`, `5280.feet.inMiles`)
// remain mathematically consistent.
// [START maps_android_utils_units_constants]
@@ -70,9 +70,9 @@ public const val MILES_PER_METER: Double = 1.0 / METERS_PER_MILE
/**
* An inline value class representing a length, distance, or elevation measurement in meters.
*
- * Construct instances using the [Number] extension properties ([meters], [m], [kilometers], [km],
- * [feet], [ft], [miles], [mi]) and convert back to raw scalar units via [toMeters], [toKilometers],
- * [toFeet], and [toMiles].
+ * Construct instances using the [Number] extension properties ([meters], [kilometers], [feet],
+ * [miles]) and convert back to raw scalar units via [inMeters], [inKilometers], [inFeet], and
+ * [inMiles].
*
* @property value The underlying measurement in meters.
*/
@@ -104,59 +104,39 @@ public value class Meters(public val value: Double) : Comparable {
public inline val Number.meters: Meters
get() = Meters(value = this.toDouble())
-/** Shorthand to create a [Meters] measurement from a [Number] of meters. */
-@get:JvmName("m")
-public inline val Number.m: Meters
- get() = Meters(value = this.toDouble())
-
/** Creates a [Meters] measurement from a [Number] of kilometers. */
@get:JvmName("kilometers")
public inline val Number.kilometers: Meters
get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
-/** Shorthand to create a [Meters] measurement from a [Number] of kilometers. */
-@get:JvmName("km")
-public inline val Number.km: Meters
- get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER)
-
/** Creates a [Meters] measurement from a [Number] of international feet. */
@get:JvmName("feet")
public inline val Number.feet: Meters
get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
-/** Shorthand to create a [Meters] measurement from a [Number] of international feet. */
-@get:JvmName("ft")
-public inline val Number.ft: Meters
- get() = Meters(value = this.toDouble() * METERS_PER_FOOT)
-
/** Creates a [Meters] measurement from a [Number] of statute miles. */
@get:JvmName("miles")
public inline val Number.miles: Meters
get() = Meters(value = this.toDouble() * METERS_PER_MILE)
-/** Shorthand to create a [Meters] measurement from a [Number] of statute miles. */
-@get:JvmName("mi")
-public inline val Number.mi: Meters
- get() = Meters(value = this.toDouble() * METERS_PER_MILE)
-
/** Returns the value of this [Meters] measurement in meters as a [Double]. */
-@get:JvmName("toMeters")
-public inline val Meters.toMeters: Double
+@get:JvmName("inMeters")
+public inline val Meters.inMeters: Double
get() = value
/** Returns the equivalent number of kilometers for this [Meters] measurement. */
-@get:JvmName("toKilometers")
-public inline val Meters.toKilometers: Double
+@get:JvmName("inKilometers")
+public inline val Meters.inKilometers: Double
get() = value / METERS_PER_KILOMETER
/** Returns the equivalent number of international feet for this [Meters] measurement. */
-@get:JvmName("toFeet")
-public inline val Meters.toFeet: Double
+@get:JvmName("inFeet")
+public inline val Meters.inFeet: Double
get() = value * FEET_PER_METER
/** Returns the equivalent number of statute miles for this [Meters] measurement. */
-@get:JvmName("toMiles")
-public inline val Meters.toMiles: Double
+@get:JvmName("inMiles")
+public inline val Meters.inMiles: Double
get() = value * MILES_PER_METER
// [END maps_android_utils_units_meters]
@@ -198,38 +178,127 @@ public enum class UnitSystem {
public companion object {
/**
- * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for road distances
- * and elevations: United States (`"US"`), Liberia (`"LR"`), Myanmar (`"MM"`), and
- * United Kingdom (`"GB"`).
+ * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for road distances:
+ * United States (`"US"`), Liberia (`"LR"`), Myanmar (`"MM"`), and United Kingdom (`"GB"`).
+ *
+ * Note: While the United Kingdom uses miles and feet for road distances (per the UK
+ * *Traffic Signs Regulations and General Directions*), UK topographic maps (Ordnance Survey)
+ * display elevations in meters. Therefore, `"GB"` resolves to [IMPERIAL] for distances
+ * ([distanceFromCountryCode]) and [METRIC] for elevations ([elevationFromCountryCode]), and
+ * [getUnitsConverter] for `"GB"` / [Locale.UK] automatically returns a converter that
+ * formats distances in feet/miles and elevations in meters.
*/
public val IMPERIAL_COUNTRY_CODES: Set = setOf("US", "LR", "MM", "GB")
/**
- * Returns the preferred [UnitSystem] for the given [locale].
+ * ISO 3166-1 alpha-2 country codes that default to [IMPERIAL] units for elevations:
+ * United States (`"US"`), Liberia (`"LR"`), and Myanmar (`"MM"`).
+ */
+ public val IMPERIAL_ELEVATION_COUNTRY_CODES: Set = setOf("US", "LR", "MM")
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given [locale].
*
- * Defaults to [Locale.getDefault], providing a framework-agnostic way to select the
- * device's appropriate unit system without requiring an Android `Context`.
+ * Note: For `"GB"` ([Locale.UK]), this returns [IMPERIAL] (the UK road-distance system),
+ * whereas [elevationFromLocale] returns [METRIC]. To obtain a [UnitsConverter] that
+ * automatically handles mixed distance/elevation locales like `"GB"`, use [getUnitsConverter].
*
* @param locale The [Locale] whose country code determines the unit system.
* @return [IMPERIAL] if the locale's country is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
*/
@JvmStatic
@JvmOverloads
- public fun fromLocale(locale: Locale = Locale.getDefault()): UnitSystem = fromCountryCode(locale.country)
+ public fun fromLocale(locale: Locale = Locale.getDefault()): UnitSystem = distanceFromLocale(locale)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose country code determines the distance unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, `"MM"`, and `"GB"`, or [METRIC] otherwise.
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun distanceFromLocale(locale: Locale = Locale.getDefault()): UnitSystem =
+ distanceFromCountryCode(locale.country)
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the given [locale] (defaulting to [Locale.getDefault]).
+ *
+ * @param locale The [Locale] whose country code determines the elevation unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, and `"MM"`, or [METRIC] otherwise (including `"GB"`).
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun elevationFromLocale(locale: Locale = Locale.getDefault()): UnitSystem =
+ elevationFromCountryCode(locale.country)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the unit system.
+ * @return [IMPERIAL] if the primary locale's country is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ public fun fromResources(resources: Resources): UnitSystem = distanceFromResources(resources)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the distance unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, `"MM"`, and `"GB"`, or [METRIC] otherwise.
+ */
+ @JvmStatic
+ public fun distanceFromResources(resources: Resources): UnitSystem =
+ distanceFromLocale(primaryLocale(resources))
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the primary locale of the given Android [resources].
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the elevation unit system.
+ * @return [IMPERIAL] for `"US"`, `"LR"`, and `"MM"`, or [METRIC] otherwise (including `"GB"`).
+ */
+ @JvmStatic
+ public fun elevationFromResources(resources: Resources): UnitSystem =
+ elevationFromLocale(primaryLocale(resources))
/**
- * Returns the preferred [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ * Returns the preferred distance [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
*
* Comparison is case-insensitive. If [countryCode] is `null`, blank, or not in
* [IMPERIAL_COUNTRY_CODES], [METRIC] is returned.
*
* @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
- * @return The corresponding [UnitSystem].
+ * @return The corresponding distance [UnitSystem].
+ */
+ @JvmStatic
+ public fun fromCountryCode(countryCode: String?): UnitSystem = distanceFromCountryCode(countryCode)
+
+ /**
+ * Returns the preferred distance [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return [IMPERIAL] if [countryCode] is in [IMPERIAL_COUNTRY_CODES], otherwise [METRIC].
+ */
+ @JvmStatic
+ public fun distanceFromCountryCode(countryCode: String?): UnitSystem {
+ if (countryCode.isNullOrBlank()) return METRIC
+ return if (countryCode.uppercase(Locale.ROOT) in IMPERIAL_COUNTRY_CODES) {
+ IMPERIAL
+ } else {
+ METRIC
+ }
+ }
+
+ /**
+ * Returns the preferred elevation [UnitSystem] for the given ISO 3166-1 alpha-2 [countryCode].
+ *
+ * @param countryCode The two-letter country code (e.g., `"US"`, `"GB"`, `"FR"`).
+ * @return [IMPERIAL] if [countryCode] is in [IMPERIAL_ELEVATION_COUNTRY_CODES], otherwise [METRIC].
*/
@JvmStatic
- public fun fromCountryCode(countryCode: String?): UnitSystem {
+ public fun elevationFromCountryCode(countryCode: String?): UnitSystem {
if (countryCode.isNullOrBlank()) return METRIC
- return if (countryCode.uppercase() in IMPERIAL_COUNTRY_CODES) {
+ return if (countryCode.uppercase(Locale.ROOT) in IMPERIAL_ELEVATION_COUNTRY_CODES) {
IMPERIAL
} else {
METRIC
@@ -256,6 +325,33 @@ public data class NumberFormatSymbols(
public val decimalSeparator: Char = '.',
public val groupingSize: Int = 3,
) {
+ /**
+ * Formats [value] rounded to [decimals] fractional digits with thousands grouping according to
+ * this [NumberFormatSymbols] instance using pure Kotlin integer arithmetic (`roundToLong`, `/`,
+ * `%`) and `buildString`, avoiding any dependency on `java.util.Formatter` (`String.format`).
+ *
+ * @param value The numeric value to format.
+ * @param decimals Number of digits after the decimal separator (must be `>= 0`; defaults to `0`).
+ * @return The formatted numeric string without a unit suffix (e.g., `"1,235"`, `"1,234.50"`).
+ * @throws IllegalArgumentException if [decimals] is negative.
+ */
+ @JvmOverloads
+ public fun formatFixedDecimal(value: Double, decimals: Int = 0): String =
+ formatFixedDecimal(value = value, decimals = decimals, symbols = this)
+
+ /**
+ * Formats a scalar distance [value] (in large units such as kilometers or miles) using logical
+ * precision based on order of magnitude and this [NumberFormatSymbols] instance:
+ * - Values `< 10` (when rounded to 1 decimal place) are formatted with `1` decimal digit,
+ * keeping the trailing `.0` (e.g., `"0.3"`, `"1.0"`, `"2.5"`, `"9.9"`).
+ * - Values `>= 10` are formatted as whole integers with `0` decimal digits and thousands
+ * grouping (e.g., `"10"`, `"1,250"`).
+ *
+ * @param value The numeric value in target units (e.g., kilometers or miles).
+ * @return The formatted numeric string without a unit suffix.
+ */
+ public fun formatDistanceValue(value: Double): String = formatDistanceValue(value = value, symbols = this)
+
public companion object {
/**
* Standard English number formatting symbols (`','` thousands separator, `'.'` decimal
@@ -278,7 +374,7 @@ public data class NumberFormatSymbols(
val groupingSize = if (decimalFormat != null && decimalFormat.isGroupingUsed) {
decimalFormat.groupingSize
} else {
- 3
+ 0
}
return NumberFormatSymbols(
groupingSeparator = dfs.groupingSeparator,
@@ -306,50 +402,32 @@ public data class NumberFormatSymbols(
// places, e.g., `"10 km"`, `"12 mi"`).
/**
- * Pairs a converted numeric measurement [value] with the Android string resource ID
- * ([unitsTemplate]) used to format it.
+ * Functional (SAM) interface for formatting a measurement in [meters] into a localized string
+ * using the provided [symbols].
*
- * @property value The converted numerical value in the target unit (e.g., feet, miles, meters, or kilometers).
- * @property unitsTemplate The `@StringRes` resource ID for formatting [value] with its unit label.
+ * Used by [UnitsConverter.create] to allow Java and Kotlin callers to supply custom distance or
+ * elevation formatters (or method references such as `MetricUnitsConverter.INSTANCE::toElevationString`).
*/
-public data class ValueWithUnitsTemplate(
- public val value: Double,
- @param:StringRes @field:StringRes @get:StringRes public val unitsTemplate: Int,
-)
+public fun interface MeasurementFormatter {
+ /**
+ * Formats a measurement of [meters] (in raw meters) using [symbols].
+ *
+ * @param meters The measurement value in meters.
+ * @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
+ * @return The formatted measurement string with units.
+ */
+ public fun format(meters: Double, symbols: NumberFormatSymbols): String
+}
/**
* Strategy for converting and formatting [Meters] measurements into human-readable distance and
* elevation strings.
*
- * Obtain an instance via [getUnitsConverter], [UnitSystem.converter], or directly reference
- * [MetricUnitsConverter] or [ImperialUnitsConverter].
+ * Obtain a standard instance via [getUnitsConverter], [UnitSystem.converter], [MetricUnitsConverter],
+ * or [ImperialUnitsConverter], or construct a composite/custom converter via [UnitsConverter] (Kotlin)
+ * or [UnitsConverter.create] (Java/Kotlin).
*/
public abstract class UnitsConverter {
- /** The [UnitSystem] implemented by this converter. */
- public abstract val unitSystem: UnitSystem
-
- /**
- * Converts a distance in [meters] to a [ValueWithUnitsTemplate] containing the scaled numeric
- * value and the appropriate string resource template ID.
- */
- public abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate
-
- /**
- * Converts a distance in [meters] (as a raw [Double]) to a [ValueWithUnitsTemplate].
- */
- public fun toDistanceUnits(meters: Double): ValueWithUnitsTemplate = toDistanceUnits(Meters(meters))
-
- /**
- * Converts an elevation in [meters] to a [ValueWithUnitsTemplate] containing the scaled numeric
- * value and the appropriate string resource template ID.
- */
- public abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate
-
- /**
- * Converts an elevation in [meters] (as a raw [Double]) to a [ValueWithUnitsTemplate].
- */
- public fun toElevationUnits(meters: Double): ValueWithUnitsTemplate = toElevationUnits(Meters(meters))
-
/**
* Formats a distance in [meters] into a human-readable string with units using pure Kotlin
* number formatting and the given [symbols].
@@ -358,10 +436,7 @@ public abstract class UnitsConverter {
* @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
* @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"10 km"`, `"528 ft"`, `"2.5 mi"`).
*/
- public fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String {
- val (value, resourceId) = toDistanceUnits(meters)
- return formatWithTemplate(resourceId, value, symbols)
- }
+ public abstract fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String
/**
* Formats a distance in [meters] (as a raw [Double]) using the given [symbols].
@@ -374,8 +449,7 @@ public abstract class UnitsConverter {
toDistanceString(Meters(meters), symbols)
/**
- * Formats a distance in [meters] into a localized string with units without requiring Android
- * [Resources].
+ * Formats a distance in [meters] into a localized string with units.
*
* @param meters The distance to format.
* @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
@@ -385,8 +459,7 @@ public abstract class UnitsConverter {
toDistanceString(meters, NumberFormatSymbols.fromLocale(locale))
/**
- * Formats a distance in [meters] (as a raw [Double]) into a localized string with units without
- * requiring Android [Resources].
+ * Formats a distance in [meters] (as a raw [Double]) into a localized string with units.
*
* @param meters The distance in meters to format.
* @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
@@ -397,21 +470,21 @@ public abstract class UnitsConverter {
toDistanceString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
/**
- * Formats a distance in [meters] using the provided Android [resources].
+ * Formats a distance in [meters] into a localized string using the translated unit templates
+ * (`R.string.amu_in_*`) and primary locale of the given Android [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
* @param meters The distance to format.
- * @return The formatted distance string.
+ * @return The formatted distance string (e.g., `"450 m"`, `"2.0 km"`, `"528 フィート"`, `"2,5 ми"`).
*/
- public fun toDistanceString(resources: Resources, meters: Meters): String {
- val (value, resourceId) = toDistanceUnits(meters = meters)
- return resources.getString(resourceId, value)
- }
+ public open fun toDistanceString(resources: Resources, meters: Meters): String =
+ toDistanceString(meters, NumberFormatSymbols.fromLocale(primaryLocale(resources)))
/**
- * Formats a distance in [meters] (as a raw [Double]) using the provided Android [resources].
+ * Formats a distance in [meters] (as a raw [Double]) into a localized string using the
+ * translated unit templates (`R.string.amu_in_*`) and primary locale of [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
* @param meters The distance in meters to format.
* @return The formatted distance string.
*/
@@ -426,10 +499,7 @@ public abstract class UnitsConverter {
* @param symbols The [NumberFormatSymbols] used for grouping and decimal separators.
* @return The formatted elevation string (e.g., `"4,401 m"` or `"14,440 ft"`).
*/
- public fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String {
- val (value, resourceId) = toElevationUnits(meters)
- return formatWithTemplate(resourceId, value, symbols)
- }
+ public abstract fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String
/**
* Formats an elevation in [meters] (as a raw [Double]) using the given [symbols].
@@ -442,8 +512,7 @@ public abstract class UnitsConverter {
toElevationString(Meters(meters), symbols)
/**
- * Formats an elevation in [meters] into a localized string with units without requiring Android
- * [Resources].
+ * Formats an elevation in [meters] into a localized string with units.
*
* @param meters The elevation to format.
* @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
@@ -453,8 +522,7 @@ public abstract class UnitsConverter {
toElevationString(meters, NumberFormatSymbols.fromLocale(locale))
/**
- * Formats an elevation in [meters] (as a raw [Double]) into a localized string with units
- * without requiring Android [Resources].
+ * Formats an elevation in [meters] (as a raw [Double]) into a localized string with units.
*
* @param meters The elevation in meters to format.
* @param locale The [Locale] used for number formatting rules (defaults to [Locale.getDefault]).
@@ -465,26 +533,241 @@ public abstract class UnitsConverter {
toElevationString(Meters(meters), NumberFormatSymbols.fromLocale(locale))
/**
- * Formats an elevation in [meters] using the provided Android [resources].
+ * Formats an elevation in [meters] into a localized string using the translated unit templates
+ * (`R.string.amu_in_*`) and primary locale of the given Android [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
* @param meters The elevation to format.
- * @return The formatted elevation string.
+ * @return The formatted elevation string (e.g., `"4,401 m"`, `"14,440 ft"`, `"14,440 フィート"`).
*/
- public fun toElevationString(resources: Resources, meters: Meters): String {
- val (value, resourceId) = toElevationUnits(meters = meters)
- return resources.getString(resourceId, value)
- }
+ public open fun toElevationString(resources: Resources, meters: Meters): String =
+ toElevationString(meters, NumberFormatSymbols.fromLocale(primaryLocale(resources)))
/**
- * Formats an elevation in [meters] (as a raw [Double]) using the provided Android [resources].
+ * Formats an elevation in [meters] (as a raw [Double]) into a localized string using the
+ * translated unit templates (`R.string.amu_in_*`) and primary locale of [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
+ * @param resources The Android [Resources] used to resolve localized unit templates and number formatting.
* @param meters The elevation in meters to format.
* @return The formatted elevation string.
*/
public fun toElevationString(resources: Resources, meters: Double): String =
toElevationString(resources, Meters(meters))
+
+ /**
+ * Returns a [UnitsConverter] that formats distances using [distanceUnits] while preserving
+ * this converter's elevation formatting.
+ *
+ * @param distanceUnits The preferred [UnitSystem] for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return A [UnitsConverter] with the updated distance unit system.
+ */
+ public fun withDistanceUnits(distanceUnits: UnitSystem): UnitsConverter =
+ withDistanceConverter(distanceUnits.converter)
+
+ /**
+ * Returns a [UnitsConverter] that formats elevations using [elevationUnits] while preserving
+ * this converter's distance formatting.
+ *
+ * @param elevationUnits The preferred [UnitSystem] for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return A [UnitsConverter] with the updated elevation unit system.
+ */
+ public fun withElevationUnits(elevationUnits: UnitSystem): UnitsConverter =
+ withElevationConverter(elevationUnits.converter)
+
+ /**
+ * Returns a [UnitsConverter] that delegates distance formatting to [distanceConverter] while
+ * preserving this converter's elevation formatting.
+ *
+ * @param distanceConverter The converter to use for [toDistanceString].
+ * @return A composite [UnitsConverter].
+ */
+ public fun withDistanceConverter(distanceConverter: UnitsConverter): UnitsConverter =
+ create(distanceConverter = distanceConverter, elevationConverter = this)
+
+ /**
+ * Returns a [UnitsConverter] that delegates elevation formatting to [elevationConverter] while
+ * preserving this converter's distance formatting.
+ *
+ * @param elevationConverter The converter to use for [toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+ public fun withElevationConverter(elevationConverter: UnitsConverter): UnitsConverter =
+ create(distanceConverter = this, elevationConverter = elevationConverter)
+
+ /**
+ * Returns a [UnitsConverter] that uses [distanceFormatter] for distances while preserving
+ * this converter's elevation formatting.
+ *
+ * @param distanceFormatter Custom formatter for distances.
+ * @return A composite [UnitsConverter].
+ */
+ public fun withDistanceFormatter(distanceFormatter: MeasurementFormatter): UnitsConverter =
+ create(distanceFormatter = distanceFormatter, elevationConverter = this)
+
+ /**
+ * Returns a [UnitsConverter] that uses [elevationFormatter] for elevations while preserving
+ * this converter's distance formatting.
+ *
+ * @param elevationFormatter Custom formatter for elevations.
+ * @return A composite [UnitsConverter].
+ */
+ public fun withElevationFormatter(elevationFormatter: MeasurementFormatter): UnitsConverter =
+ create(distanceConverter = this, elevationFormatter = elevationFormatter)
+
+ public companion object {
+ private val IMPERIAL_DISTANCE_METRIC_ELEVATION_CONVERTER: UnitsConverter by lazy(
+ LazyThreadSafetyMode.PUBLICATION,
+ ) {
+ CompositeUnitsConverter(
+ distanceFormatter = ImperialUnitsConverter::toDistanceString,
+ elevationFormatter = MetricUnitsConverter::toElevationString,
+ distanceResourcesFormatter = ImperialUnitsConverter::toDistanceString,
+ elevationResourcesFormatter = MetricUnitsConverter::toElevationString,
+ distanceDelegate = ImperialUnitsConverter,
+ elevationDelegate = MetricUnitsConverter,
+ )
+ }
+
+ private val METRIC_DISTANCE_IMPERIAL_ELEVATION_CONVERTER: UnitsConverter by lazy(
+ LazyThreadSafetyMode.PUBLICATION,
+ ) {
+ CompositeUnitsConverter(
+ distanceFormatter = MetricUnitsConverter::toDistanceString,
+ elevationFormatter = ImperialUnitsConverter::toElevationString,
+ distanceResourcesFormatter = MetricUnitsConverter::toDistanceString,
+ elevationResourcesFormatter = ImperialUnitsConverter::toElevationString,
+ distanceDelegate = MetricUnitsConverter,
+ elevationDelegate = ImperialUnitsConverter,
+ )
+ }
+
+ /**
+ * Creates a [UnitsConverter] using the specified [distanceUnits] and [elevationUnits].
+ *
+ * @param distanceUnits The [UnitSystem] to use for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @param elevationUnits The [UnitSystem] to use for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return The corresponding [UnitsConverter].
+ */
+ @JvmStatic
+ public fun create(distanceUnits: UnitSystem, elevationUnits: UnitSystem): UnitsConverter =
+ create(distanceUnits.converter, elevationUnits.converter)
+
+ /**
+ * Creates a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and elevation formatting to [elevationConverter].
+ *
+ * For example, to format road distances in Imperial units (`ft` / `mi`) and elevations in
+ * Metric units (`m`) for the UK:
+ * ```java
+ * UnitsConverter ukConverter = UnitsConverter.create(
+ * ImperialUnitsConverter.INSTANCE,
+ * MetricUnitsConverter.INSTANCE
+ * );
+ * ```
+ */
+ @JvmStatic
+ public fun create(distanceConverter: UnitsConverter, elevationConverter: UnitsConverter): UnitsConverter {
+ val resolvedDistance =
+ (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ val resolvedElevation =
+ (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return when {
+ resolvedDistance === MetricUnitsConverter && resolvedElevation === MetricUnitsConverter ->
+ MetricUnitsConverter
+
+ resolvedDistance === ImperialUnitsConverter && resolvedElevation === ImperialUnitsConverter ->
+ ImperialUnitsConverter
+
+ resolvedDistance === ImperialUnitsConverter && resolvedElevation === MetricUnitsConverter ->
+ IMPERIAL_DISTANCE_METRIC_ELEVATION_CONVERTER
+
+ resolvedDistance === MetricUnitsConverter && resolvedElevation === ImperialUnitsConverter ->
+ METRIC_DISTANCE_IMPERIAL_ELEVATION_CONVERTER
+
+ else ->
+ CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = resolvedElevation::toElevationString,
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ distanceDelegate = resolvedDistance,
+ elevationDelegate = resolvedElevation,
+ )
+ }
+ }
+
+ /**
+ * Creates a custom [UnitsConverter] using the supplied [distanceFormatter] and
+ * [elevationFormatter].
+ */
+ @JvmStatic
+ public fun create(
+ distanceFormatter: MeasurementFormatter,
+ elevationFormatter: MeasurementFormatter,
+ ): UnitsConverter = CompositeUnitsConverter(
+ distanceFormatter = { meters, symbols -> distanceFormatter.format(meters.value, symbols) },
+ elevationFormatter = { meters, symbols -> elevationFormatter.format(meters.value, symbols) },
+ )
+
+ /**
+ * Creates a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and uses [elevationFormatter] for elevations.
+ */
+ @JvmStatic
+ public fun create(
+ distanceConverter: UnitsConverter,
+ elevationFormatter: MeasurementFormatter,
+ ): UnitsConverter {
+ val resolvedDistance =
+ (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = { meters, symbols -> elevationFormatter.format(meters.value, symbols) },
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ distanceDelegate = resolvedDistance,
+ )
+ }
+
+ /**
+ * Creates a composite [UnitsConverter] that uses [distanceFormatter] for distances and
+ * delegates elevation formatting to [elevationConverter].
+ */
+ @JvmStatic
+ public fun create(
+ distanceFormatter: MeasurementFormatter,
+ elevationConverter: UnitsConverter,
+ ): UnitsConverter {
+ val resolvedElevation =
+ (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = { meters, symbols -> distanceFormatter.format(meters.value, symbols) },
+ elevationFormatter = resolvedElevation::toElevationString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ elevationDelegate = resolvedElevation,
+ )
+ }
+ }
+}
+
+private class CompositeUnitsConverter(
+ private val distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ private val elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+ private val distanceResourcesFormatter: ((Resources, Meters) -> String)? = null,
+ private val elevationResourcesFormatter: ((Resources, Meters) -> String)? = null,
+ internal val distanceDelegate: UnitsConverter? = null,
+ internal val elevationDelegate: UnitsConverter? = null,
+) : UnitsConverter() {
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String =
+ distanceFormatter(meters, symbols)
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String =
+ distanceResourcesFormatter?.invoke(resources, meters) ?: super.toDistanceString(resources, meters)
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ elevationFormatter(meters, symbols)
+
+ override fun toElevationString(resources: Resources, meters: Meters): String =
+ elevationResourcesFormatter?.invoke(resources, meters) ?: super.toElevationString(resources, meters)
}
// [START maps_android_utils_units_converters]
@@ -492,38 +775,70 @@ public abstract class UnitsConverter {
/**
* Renders distance and elevation measurements in Imperial units (`ft` and `mi`).
*
- * - **Distances** below `0.25` miles (`1,320` feet) are displayed in whole feet (`ft`).
+ * - **Distances** rounding to below `0.25` miles (`1,320` feet) are displayed in whole feet (`ft`).
* - **Distances** from `0.25` miles up to `< 10` miles are displayed in miles (`mi`) with `1`
* decimal digit (keeping trailing `.0`, e.g., `"0.3 mi"`, `"1.0 mi"`, `"2.5 mi"`).
* - **Distances** of `10` miles or greater are displayed in whole miles (`mi`, e.g., `"10 mi"`).
* - **Elevations** are always displayed in whole feet (`ft`).
*/
public object ImperialUnitsConverter : UnitsConverter() {
+ private const val THRESHOLD_FEET: Long = 1_320L
+
/** Threshold (`0.25` miles = `1,320` feet) at which imperial distances switch from feet to miles. */
+ @get:JvmName("getMilesThreshold")
public val MILES_THRESHOLD: Meters = 0.25.miles
- override val unitSystem: UnitSystem = UnitSystem.IMPERIAL
+ /** The [UnitSystem] implemented by this converter ([UnitSystem.IMPERIAL]). */
+ public val unitSystem: UnitSystem = UnitSystem.IMPERIAL
- override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate =
- if (abs(meters.value) < MILES_THRESHOLD.value) {
- ValueWithUnitsTemplate(normalizeZero(meters.toFeet, decimals = 0), R.string.amu_in_feet)
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String = formatDistance(
+ meters = meters,
+ symbols = symbols,
+ formatFeet = { "$it ft" },
+ formatMiles = { "$it mi" },
+ )
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String = formatDistance(
+ meters = meters,
+ symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources)),
+ formatFeet = { resources.getString(R.string.amu_in_feet, it) },
+ formatMiles = { resources.getString(R.string.amu_in_miles, it) },
+ )
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ "${symbols.formatFixedDecimal(meters.inFeet, decimals = 0)} ft"
+
+ override fun toElevationString(resources: Resources, meters: Meters): String {
+ val symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources))
+ return resources.getString(R.string.amu_in_feet, symbols.formatFixedDecimal(meters.inFeet, decimals = 0))
+ }
+
+ private inline fun formatDistance(
+ meters: Meters,
+ symbols: NumberFormatSymbols,
+ formatFeet: (String) -> String,
+ formatMiles: (String) -> String,
+ ): String {
+ val feet = meters.inFeet
+ return if (abs(feet).roundToLong() < THRESHOLD_FEET) {
+ formatFeet(symbols.formatFixedDecimal(feet, decimals = 0))
} else {
- val miles = meters.toMiles
- if (isUnderTenAtOneDecimal(miles)) {
- ValueWithUnitsTemplate(normalizeZero(miles, decimals = 1), R.string.amu_in_miles)
- } else {
- ValueWithUnitsTemplate(normalizeZero(miles, decimals = 0), R.string.amu_in_miles_whole)
- }
+ val rawMiles = meters.inMiles
+ val miles =
+ if (abs(rawMiles) < 0.25) {
+ if (rawMiles < 0.0) -0.25 else 0.25
+ } else {
+ rawMiles
+ }
+ formatMiles(symbols.formatDistanceValue(miles))
}
-
- override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate =
- ValueWithUnitsTemplate(normalizeZero(meters.toFeet, decimals = 0), R.string.amu_in_feet)
+ }
}
/**
* Renders distance and elevation measurements in Metric units (`m` and `km`).
*
- * - **Distances** below `1,000` meters are displayed in whole meters (`m`).
+ * - **Distances** rounding to below `1,000` meters are displayed in whole meters (`m`).
* - **Distances** from `1,000` meters (`1.0` km) up to `< 10` kilometers are displayed in
* kilometers (`km`) with `1` decimal digit (keeping trailing `.0`, e.g., `"1.0 km"`, `"2.5 km"`).
* - **Distances** of `10` kilometers or greater are displayed in whole kilometers (`km`, e.g., `"10 km"`).
@@ -531,24 +846,47 @@ public object ImperialUnitsConverter : UnitsConverter() {
*/
public object MetricUnitsConverter : UnitsConverter() {
/** Threshold (`1,000` meters = `1` kilometer) at which metric distances switch from meters to kilometers. */
+ @get:JvmName("getKilometersThreshold")
public val KILOMETERS_THRESHOLD: Meters = METERS_PER_KILOMETER.meters
- override val unitSystem: UnitSystem = UnitSystem.METRIC
+ /** The [UnitSystem] implemented by this converter ([UnitSystem.METRIC]). */
+ public val unitSystem: UnitSystem = UnitSystem.METRIC
+
+ override fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String = formatDistance(
+ meters = meters,
+ symbols = symbols,
+ formatMeters = { "$it m" },
+ formatKilometers = { "$it km" },
+ )
+
+ override fun toDistanceString(resources: Resources, meters: Meters): String = formatDistance(
+ meters = meters,
+ symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources)),
+ formatMeters = { resources.getString(R.string.amu_in_meters, it) },
+ formatKilometers = { resources.getString(R.string.amu_in_kilometers, it) },
+ )
+
+ override fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String =
+ "${symbols.formatFixedDecimal(meters.inMeters, decimals = 0)} m"
+
+ override fun toElevationString(resources: Resources, meters: Meters): String {
+ val symbols = NumberFormatSymbols.fromLocale(primaryLocale(resources))
+ return resources.getString(R.string.amu_in_meters, symbols.formatFixedDecimal(meters.inMeters, decimals = 0))
+ }
- override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate =
- if (abs(meters.value) < KILOMETERS_THRESHOLD.value) {
- ValueWithUnitsTemplate(normalizeZero(meters.toMeters, decimals = 0), R.string.amu_in_meters)
+ private inline fun formatDistance(
+ meters: Meters,
+ symbols: NumberFormatSymbols,
+ formatMeters: (String) -> String,
+ formatKilometers: (String) -> String,
+ ): String {
+ val metersValue = meters.inMeters
+ return if (abs(metersValue).roundToLong() < METERS_PER_KILOMETER) {
+ formatMeters(symbols.formatFixedDecimal(metersValue, decimals = 0))
} else {
- val kilometers = meters.toKilometers
- if (isUnderTenAtOneDecimal(kilometers)) {
- ValueWithUnitsTemplate(normalizeZero(kilometers, decimals = 1), R.string.amu_in_kilometers)
- } else {
- ValueWithUnitsTemplate(normalizeZero(kilometers, decimals = 0), R.string.amu_in_kilometers_whole)
- }
+ formatKilometers(symbols.formatDistanceValue(meters.inKilometers))
}
-
- override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate =
- ValueWithUnitsTemplate(normalizeZero(meters.toMeters, decimals = 0), R.string.amu_in_meters)
+ }
}
// [END maps_android_utils_units_converters]
@@ -557,29 +895,167 @@ public object MetricUnitsConverter : UnitsConverter() {
// =================================================================================================
/**
- * Returns the appropriate [UnitsConverter] for the given [locale] (defaulting to [Locale.getDefault]).
+ * Constructs a [UnitsConverter] using the specified [distanceUnits] and [elevationUnits].
+ *
+ * @param distanceUnits The [UnitSystem] to use for distances ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @param elevationUnits The [UnitSystem] to use for elevations ([UnitSystem.METRIC] or [UnitSystem.IMPERIAL]).
+ * @return The corresponding [UnitsConverter].
+ */
+public fun UnitsConverter(distanceUnits: UnitSystem, elevationUnits: UnitSystem): UnitsConverter =
+ UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Constructs a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and elevation formatting to [elevationConverter].
+ *
+ * Example (Imperial road distances with Metric elevations for the UK):
+ * ```kotlin
+ * val ukConverter = UnitsConverter(
+ * distanceConverter = ImperialUnitsConverter,
+ * elevationConverter = MetricUnitsConverter,
+ * )
+ * ```
+ *
+ * @param distanceConverter Converter used for [UnitsConverter.toDistanceString].
+ * @param elevationConverter Converter used for [UnitsConverter.toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+public fun UnitsConverter(distanceConverter: UnitsConverter, elevationConverter: UnitsConverter): UnitsConverter =
+ UnitsConverter.create(distanceConverter, elevationConverter)
+
+/**
+ * Constructs a custom [UnitsConverter] using the provided [distanceFormatter] and
+ * [elevationFormatter] lambdas (or method references such as `MetricUnitsConverter::toElevationString`).
+ *
+ * Example:
+ * ```kotlin
+ * val customConverter = UnitsConverter(
+ * distanceFormatter = { meters, symbols ->
+ * "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ * },
+ * elevationFormatter = MetricUnitsConverter::toElevationString,
+ * )
+ * ```
+ *
+ * @param distanceFormatter Lambda formatting a [Meters] distance with [NumberFormatSymbols].
+ * @param elevationFormatter Lambda formatting a [Meters] elevation with [NumberFormatSymbols].
+ * @return A custom [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+): UnitsConverter = CompositeUnitsConverter(
+ distanceFormatter = distanceFormatter,
+ elevationFormatter = elevationFormatter,
+)
+
+/**
+ * Constructs a composite [UnitsConverter] that delegates distance formatting to
+ * [distanceConverter] and uses [elevationFormatter] for elevations.
+ *
+ * @param distanceConverter Converter used for [UnitsConverter.toDistanceString].
+ * @param elevationFormatter Lambda formatting a [Meters] elevation with [NumberFormatSymbols].
+ * @return A composite [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceConverter: UnitsConverter,
+ elevationFormatter: (Meters, NumberFormatSymbols) -> String,
+): UnitsConverter {
+ val resolvedDistance = (distanceConverter as? CompositeUnitsConverter)?.distanceDelegate ?: distanceConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = resolvedDistance::toDistanceString,
+ elevationFormatter = elevationFormatter,
+ distanceResourcesFormatter = resolvedDistance::toDistanceString,
+ distanceDelegate = resolvedDistance,
+ )
+}
+
+/**
+ * Constructs a composite [UnitsConverter] that uses [distanceFormatter] for distances and
+ * delegates elevation formatting to [elevationConverter].
+ *
+ * @param distanceFormatter Lambda formatting a [Meters] distance with [NumberFormatSymbols].
+ * @param elevationConverter Converter used for [UnitsConverter.toElevationString].
+ * @return A composite [UnitsConverter].
+ */
+@JvmSynthetic
+public fun UnitsConverter(
+ distanceFormatter: (Meters, NumberFormatSymbols) -> String,
+ elevationConverter: UnitsConverter,
+): UnitsConverter {
+ val resolvedElevation = (elevationConverter as? CompositeUnitsConverter)?.elevationDelegate ?: elevationConverter
+ return CompositeUnitsConverter(
+ distanceFormatter = distanceFormatter,
+ elevationFormatter = resolvedElevation::toElevationString,
+ elevationResourcesFormatter = resolvedElevation::toElevationString,
+ elevationDelegate = resolvedElevation,
+ )
+}
+
+/**
+ * Returns the appropriate [UnitsConverter] for the given [locale] (defaulting to [Locale.getDefault]),
+ * with optional overrides for [distanceUnits] or [elevationUnits].
+ *
+ * For `"GB"` ([Locale.UK]), distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations
+ * default to [UnitSystem.METRIC] (`m`).
+ *
+ * @param locale The [Locale] used to determine the default distance and elevation unit systems.
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromLocale]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromLocale]).
+ * @return The resolved [UnitsConverter].
+ */
+@JvmOverloads
+public fun getUnitsConverter(
+ locale: Locale = Locale.getDefault(),
+ distanceUnits: UnitSystem = UnitSystem.distanceFromLocale(locale),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromLocale(locale),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
+
+/**
+ * Returns the appropriate [UnitsConverter] for the primary locale of the given Android [resources],
+ * with optional overrides for [distanceUnits] or [elevationUnits].
*
- * @param locale The [Locale] used to determine the preferred unit system.
- * @return [ImperialUnitsConverter] for imperial locales (`US`, `LR`, `MM`, `GB`), or
- * [MetricUnitsConverter] otherwise.
+ * For `"GB"` ([Locale.UK]), distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations
+ * default to [UnitSystem.METRIC] (`m`).
+ *
+ * @param resources The Android [Resources] whose configuration locale determines the default unit systems.
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromResources]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromResources]).
+ * @return The resolved [UnitsConverter].
*/
@JvmOverloads
-public fun getUnitsConverter(locale: Locale = Locale.getDefault()): UnitsConverter =
- UnitSystem.fromLocale(locale).converter
+public fun getUnitsConverter(
+ resources: Resources,
+ distanceUnits: UnitSystem = UnitSystem.distanceFromResources(resources),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromResources(resources),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
/**
- * Returns the appropriate [UnitsConverter] for the given ISO 3166-1 alpha-2 [countryCode].
+ * Returns the appropriate [UnitsConverter] for the given ISO 3166-1 alpha-2 [countryCode],
+ * with optional overrides for [distanceUnits] or [elevationUnits].
+ *
+ * For `"GB"`, distances default to [UnitSystem.IMPERIAL] (`ft` / `mi`) and elevations default to
+ * [UnitSystem.METRIC] (`m`).
*
* @param countryCode The two-letter country code (case-insensitive), or `null`.
- * @return [ImperialUnitsConverter] if [countryCode] is in [UnitSystem.IMPERIAL_COUNTRY_CODES],
- * otherwise [MetricUnitsConverter].
+ * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromCountryCode]).
+ * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromCountryCode]).
+ * @return The resolved [UnitsConverter].
*/
-public fun getUnitsConverter(countryCode: String?): UnitsConverter = UnitSystem.fromCountryCode(countryCode).converter
+@JvmOverloads
+public fun getUnitsConverter(
+ countryCode: String?,
+ distanceUnits: UnitSystem = UnitSystem.distanceFromCountryCode(countryCode),
+ elevationUnits: UnitSystem = UnitSystem.elevationFromCountryCode(countryCode),
+): UnitsConverter = UnitsConverter.create(distanceUnits, elevationUnits)
/**
- * Returns the [UnitsConverter] corresponding to the specified [unitSystem].
+ * Returns the [UnitsConverter] corresponding to the specified [unitSystem] for both distances
+ * and elevations.
*
- * @param unitSystem The desired [UnitSystem].
+ * @param unitSystem The desired [UnitSystem] for both distances and elevations.
* @return [MetricUnitsConverter] or [ImperialUnitsConverter].
*/
public fun getUnitsConverter(unitSystem: UnitSystem): UnitsConverter = unitSystem.converter
@@ -648,16 +1124,19 @@ public fun Meters.toDistanceString(
): String = converter.toDistanceString(this, symbols)
/**
- * Formats this [Meters] measurement as a human-readable distance string using Android [resources].
+ * Formats this [Meters] measurement as a localized distance string using the translated unit
+ * templates (`R.string.amu_in_*`) and primary locale of [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
- * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
+ * @param resources The Android [Resources] used for unit selection, number formatting, and localized unit labels.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [resources]).
* @return The formatted distance string.
*/
@JvmName("toDistanceString")
@JvmOverloads
-public fun Meters.toDistanceString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
- converter.toDistanceString(resources, this)
+public fun Meters.toDistanceString(
+ resources: Resources,
+ converter: UnitsConverter = getUnitsConverter(resources),
+): String = converter.toDistanceString(resources, this)
/**
* Formats this [Meters] measurement as a human-readable elevation string using [locale] and [converter].
@@ -689,21 +1168,27 @@ public fun Meters.toElevationString(
): String = converter.toElevationString(this, symbols)
/**
- * Formats this [Meters] measurement as a human-readable elevation string using Android [resources].
+ * Formats this [Meters] measurement as a localized elevation string using the translated unit
+ * templates (`R.string.amu_in_*`) and primary locale of [resources].
*
- * @param resources Android [Resources] used to resolve the unit string template.
- * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter]).
+ * @param resources The Android [Resources] used for unit selection, number formatting, and localized unit labels.
+ * @param converter The [UnitsConverter] to use (defaults to [getUnitsConverter] for [resources]).
* @return The formatted elevation string.
*/
@JvmName("toElevationString")
@JvmOverloads
-public fun Meters.toElevationString(resources: Resources, converter: UnitsConverter = getUnitsConverter()): String =
- converter.toElevationString(resources, this)
+public fun Meters.toElevationString(
+ resources: Resources,
+ converter: UnitsConverter = getUnitsConverter(resources),
+): String = converter.toElevationString(resources, this)
// =================================================================================================
// § 6. Internal Pure-Kotlin Formatting Helpers
// =================================================================================================
+private fun primaryLocale(resources: Resources): Locale =
+ ConfigurationCompat.getLocales(resources.configuration)[0] ?: Locale.getDefault()
+
// [START maps_android_utils_units_pure_kotlin_formatter]
/**
@@ -714,29 +1199,17 @@ public fun Meters.toElevationString(resources: Resources, converter: UnitsConver
private fun isUnderTenAtOneDecimal(value: Double): Boolean = (abs(value) * 10.0).roundToLong() < 100L
/**
- * Prevents negative values that round to zero at the given [decimals] precision from formatting
- * as `"-0"` or `"-0.0"`.
- */
-private fun normalizeZero(value: Double, decimals: Int): Double {
- val factor = if (decimals == 0) 1.0 else 10.0
- if (abs(value) * factor < 0.5) return 0.0
- return value
-}
-
-/**
- * Formats [value] with `0` or `1` decimal places and locale-specific thousands grouping using pure
+ * Formats [value] with [decimals] fractional digits and locale-specific thousands grouping using pure
* `kotlin.math` integer arithmetic (`roundToLong`, `/`, `%`) and `buildString`, avoiding any
* dependency on `java.util.Formatter` (`String.format`).
*/
-internal fun formatFixedDecimal(
- value: Double,
- decimals: Int,
- symbols: NumberFormatSymbols = NumberFormatSymbols.DEFAULT,
-): String {
- val factor = if (decimals <= 0) 1L else 10L
+internal fun formatFixedDecimal(value: Double, decimals: Int, symbols: NumberFormatSymbols): String {
+ require(decimals >= 0) { "decimals must be non-negative, was $decimals" }
+ var factor = 1L
+ repeat(decimals) { factor *= 10L }
val scaled = (abs(value) * factor).roundToLong()
val isNegative = value < 0.0 && scaled > 0L
- val integerPart = if (decimals <= 0) scaled else scaled / 10L
+ val integerPart = scaled / factor
val groupedInteger = formatGroupedInteger(
value = integerPart,
groupingSeparator = symbols.groupingSeparator,
@@ -747,7 +1220,7 @@ internal fun formatFixedDecimal(
append(groupedInteger)
if (decimals > 0) {
append(symbols.decimalSeparator)
- append(scaled % 10L)
+ append((scaled % factor).toString().padStart(decimals, '0'))
}
}
}
@@ -756,7 +1229,7 @@ internal fun formatFixedDecimal(
* Formats a non-negative integer [value] with [groupingSeparator] inserted every [groupingSize]
* digits from the right.
*/
-internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, groupingSize: Int = 3): String {
+internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, groupingSize: Int): String {
val digits = value.toString()
if (groupingSize <= 0 || digits.length <= groupingSize) {
return digits
@@ -771,18 +1244,3 @@ internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, grouping
}
}
// [END maps_android_utils_units_pure_kotlin_formatter]
-
-/**
- * Formats [value] with its unit suffix according to [resourceId] and [symbols] without requiring
- * Android [Resources] or `java.util.Formatter`.
- */
-private fun formatWithTemplate(@StringRes resourceId: Int, value: Double, symbols: NumberFormatSymbols): String =
- when (resourceId) {
- R.string.amu_in_feet -> "${formatFixedDecimal(value, decimals = 0, symbols)} ft"
- R.string.amu_in_miles -> "${formatFixedDecimal(value, decimals = 1, symbols)} mi"
- R.string.amu_in_miles_whole -> "${formatFixedDecimal(value, decimals = 0, symbols)} mi"
- R.string.amu_in_meters -> "${formatFixedDecimal(value, decimals = 0, symbols)} m"
- R.string.amu_in_kilometers -> "${formatFixedDecimal(value, decimals = 1, symbols)} km"
- R.string.amu_in_kilometers_whole -> "${formatFixedDecimal(value, decimals = 0, symbols)} km"
- else -> formatFixedDecimal(value, decimals = 1, symbols)
- }
diff --git a/library/src/main/res/values-ar/strings.xml b/library/src/main/res/values-ar/strings.xml
new file mode 100644
index 000000000..1ba42c5db
--- /dev/null
+++ b/library/src/main/res/values-ar/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s قدم
+ %1$s ميل
+ %1$s م
+ %1$s كم
+
diff --git a/library/src/main/res/values-bg/strings.xml b/library/src/main/res/values-bg/strings.xml
new file mode 100644
index 000000000..333ad723b
--- /dev/null
+++ b/library/src/main/res/values-bg/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фута
+ %1$s мили
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-de/strings.xml b/library/src/main/res/values-de/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-de/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-el/strings.xml b/library/src/main/res/values-el/strings.xml
new file mode 100644
index 000000000..3b7cf5a56
--- /dev/null
+++ b/library/src/main/res/values-el/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s πόδ.
+ %1$s μίλ.
+ %1$s m
+ %1$s χλμ.
+
diff --git a/library/src/main/res/values-es/strings.xml b/library/src/main/res/values-es/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-es/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-fa/strings.xml b/library/src/main/res/values-fa/strings.xml
new file mode 100644
index 000000000..2811dfe80
--- /dev/null
+++ b/library/src/main/res/values-fa/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s فوت
+ %1$s مایل
+ %1$s متر
+ %1$s کیلومتر
+
diff --git a/library/src/main/res/values-fr/strings.xml b/library/src/main/res/values-fr/strings.xml
new file mode 100644
index 000000000..ffd7e89c0
--- /dev/null
+++ b/library/src/main/res/values-fr/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s pi
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-hi/strings.xml b/library/src/main/res/values-hi/strings.xml
new file mode 100644
index 000000000..6eabc12f3
--- /dev/null
+++ b/library/src/main/res/values-hi/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s फ़ीट
+ %1$s मील
+ %1$s मी॰
+ %1$s कि॰मी॰
+
diff --git a/library/src/main/res/values-in/strings.xml b/library/src/main/res/values-in/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-in/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-it/strings.xml b/library/src/main/res/values-it/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-it/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-iw/strings.xml b/library/src/main/res/values-iw/strings.xml
new file mode 100644
index 000000000..e24c890c4
--- /dev/null
+++ b/library/src/main/res/values-iw/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s רגל
+ %1$s מייל
+ %1$s מ׳
+ %1$s ק״מ
+
diff --git a/library/src/main/res/values-ja/strings.xml b/library/src/main/res/values-ja/strings.xml
new file mode 100644
index 000000000..6f09c1219
--- /dev/null
+++ b/library/src/main/res/values-ja/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s フィート
+ %1$s マイル
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-ko/strings.xml b/library/src/main/res/values-ko/strings.xml
new file mode 100644
index 000000000..bdca1df67
--- /dev/null
+++ b/library/src/main/res/values-ko/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s피트
+ %1$s마일
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-nl/strings.xml b/library/src/main/res/values-nl/strings.xml
new file mode 100644
index 000000000..d82f180a9
--- /dev/null
+++ b/library/src/main/res/values-nl/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-pl/strings.xml b/library/src/main/res/values-pl/strings.xml
new file mode 100644
index 000000000..9a541d697
--- /dev/null
+++ b/library/src/main/res/values-pl/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s st.
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-pt/strings.xml b/library/src/main/res/values-pt/strings.xml
new file mode 100644
index 000000000..7532ea94f
--- /dev/null
+++ b/library/src/main/res/values-pt/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s pés
+ %1$s mi
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-ru/strings.xml b/library/src/main/res/values-ru/strings.xml
new file mode 100644
index 000000000..045d2c15b
--- /dev/null
+++ b/library/src/main/res/values-ru/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фт
+ %1$s ми
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-th/strings.xml b/library/src/main/res/values-th/strings.xml
new file mode 100644
index 000000000..33e845816
--- /dev/null
+++ b/library/src/main/res/values-th/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ฟุต
+ %1$s ไมล์
+ %1$s ม.
+ %1$s กม.
+
diff --git a/library/src/main/res/values-tr/strings.xml b/library/src/main/res/values-tr/strings.xml
new file mode 100644
index 000000000..63b463320
--- /dev/null
+++ b/library/src/main/res/values-tr/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s fit
+ %1$s mil
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-uk/strings.xml b/library/src/main/res/values-uk/strings.xml
new file mode 100644
index 000000000..f24540773
--- /dev/null
+++ b/library/src/main/res/values-uk/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s фт
+ %1$s милі
+ %1$s м
+ %1$s км
+
diff --git a/library/src/main/res/values-vi/strings.xml b/library/src/main/res/values-vi/strings.xml
new file mode 100644
index 000000000..9ee478274
--- /dev/null
+++ b/library/src/main/res/values-vi/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s ft
+ %1$s dặm
+ %1$s m
+ %1$s km
+
diff --git a/library/src/main/res/values-zh-rCN/strings.xml b/library/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 000000000..1ea7acc0c
--- /dev/null
+++ b/library/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 米
+ %1$s 公里
+
diff --git a/library/src/main/res/values-zh-rHK/strings.xml b/library/src/main/res/values-zh-rHK/strings.xml
new file mode 100644
index 000000000..4eaf3018b
--- /dev/null
+++ b/library/src/main/res/values-zh-rHK/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 公尺
+ %1$s 公里
+
diff --git a/library/src/main/res/values-zh-rTW/strings.xml b/library/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 000000000..4eaf3018b
--- /dev/null
+++ b/library/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ %1$s 英尺
+ %1$s 英里
+ %1$s 公尺
+ %1$s 公里
+
diff --git a/library/src/main/res/values/strings.xml b/library/src/main/res/values/strings.xml
index 59fbbfafa..6fefcc380 100644
--- a/library/src/main/res/values/strings.xml
+++ b/library/src/main/res/values/strings.xml
@@ -16,40 +16,22 @@
-->
- %1$,.0f ft
+ %1$s ft
- %1$,.1f mi
+ %1$s mi
- %1$,.0f mi
+ %1$s m
- %1$,.0f m
-
-
- %1$,.1f km
-
-
- %1$,.0f km
+ %1$s km
diff --git a/library/src/test/java/com/google/maps/android/UnitsJavaTest.java b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
index 283d77d51..d88beed48 100644
--- a/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
+++ b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java
@@ -17,27 +17,24 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.maps.android.Units.feet;
-import static com.google.maps.android.Units.ft;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.inMeters;
+import static com.google.maps.android.Units.inMiles;
import static com.google.maps.android.Units.kilometers;
-import static com.google.maps.android.Units.km;
-import static com.google.maps.android.Units.m;
import static com.google.maps.android.Units.meters;
-import static com.google.maps.android.Units.mi;
import static com.google.maps.android.Units.miles;
import static com.google.maps.android.Units.toDistanceString;
import static com.google.maps.android.Units.toElevationString;
-import static com.google.maps.android.Units.toFeet;
-import static com.google.maps.android.Units.toKilometers;
-import static com.google.maps.android.Units.toMeters;
-import static com.google.maps.android.Units.toMiles;
import android.content.Context;
+import android.content.res.Configuration;
import android.content.res.Resources;
-import androidx.test.core.app.ApplicationProvider;
import java.util.Locale;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
/**
* Verifies the Java interoperability surface for {@link Units}, {@link UnitSystem}, {@link
@@ -53,24 +50,20 @@ public void unitConversions_workIdiomaticallyFromJava() {
// Expressing numbers in meters from Java via static factory methods
double twelveFeetInMeters = feet(12);
assertThat(twelveFeetInMeters).isWithin(1e-9).of(12 * Units.METERS_PER_FOOT);
- assertThat(ft(12)).isEqualTo(twelveFeetInMeters);
- assertThat(toFeet(twelveFeetInMeters)).isWithin(1e-9).of(12.0);
+ assertThat(inFeet(twelveFeetInMeters)).isWithin(1e-9).of(12.0);
double twoAndHalfKmInMeters = kilometers(2.5);
assertThat(twoAndHalfKmInMeters).isEqualTo(2_500.0);
- assertThat(km(2.5)).isEqualTo(twoAndHalfKmInMeters);
- assertThat(toKilometers(twoAndHalfKmInMeters)).isWithin(1e-9).of(2.5);
+ assertThat(inKilometers(twoAndHalfKmInMeters)).isWithin(1e-9).of(2.5);
double quarterMileInMeters = miles(0.25);
assertThat(quarterMileInMeters).isWithin(1e-9).of(1_320 * Units.METERS_PER_FOOT);
- assertThat(mi(0.25)).isEqualTo(quarterMileInMeters);
- assertThat(toMiles(quarterMileInMeters)).isWithin(1e-9).of(0.25);
- assertThat(toFeet(quarterMileInMeters)).isWithin(1e-9).of(1_320.0);
+ assertThat(inMiles(quarterMileInMeters)).isWithin(1e-9).of(0.25);
+ assertThat(inFeet(quarterMileInMeters)).isWithin(1e-9).of(1_320.0);
double hundredMeters = meters(100);
assertThat(hundredMeters).isEqualTo(100.0);
- assertThat(m(100)).isEqualTo(100.0);
- assertThat(toMeters(hundredMeters)).isEqualTo(100.0);
+ assertThat(inMeters(hundredMeters)).isEqualTo(100.0);
// [END maps_android_utils_units_java_conversions]
}
@@ -85,6 +78,10 @@ public void unitSystemAndConverters_resolveFromJava() {
UnitsConverter metric = Units.getUnitsConverter(UnitSystem.METRIC);
assertThat(imperial).isSameInstanceAs(ImperialUnitsConverter.INSTANCE);
assertThat(metric).isSameInstanceAs(MetricUnitsConverter.INSTANCE);
+
+ // Threshold getters are exposed without inline value class mangling
+ assertThat(ImperialUnitsConverter.INSTANCE.getMilesThreshold()).isWithin(1e-9).of(miles(0.25));
+ assertThat(MetricUnitsConverter.INSTANCE.getKilometersThreshold()).isEqualTo(kilometers(1.0));
}
@Test
@@ -94,33 +91,84 @@ public void distanceAndElevationFormatting_worksFromJava() {
// Formatting via converter instance methods
assertThat(imperial.toDistanceString(feet(528), Locale.US)).isEqualTo("528 ft");
+ assertThat(imperial.toDistanceString(feet(1319.6), Locale.US)).isEqualTo("0.3 mi");
assertThat(imperial.toDistanceString(miles(0.25), Locale.US)).isEqualTo("0.3 mi");
assertThat(imperial.toDistanceString(miles(2.0), Locale.US)).isEqualTo("2.0 mi");
assertThat(imperial.toDistanceString(miles(12.4), Locale.US)).isEqualTo("12 mi");
assertThat(imperial.toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
assertThat(metric.toDistanceString(meters(450), Locale.US)).isEqualTo("450 m");
- assertThat(metric.toDistanceString(km(2.0), Locale.US)).isEqualTo("2.0 km");
- assertThat(metric.toDistanceString(km(12.4), Locale.US)).isEqualTo("12 km");
+ assertThat(metric.toDistanceString(meters(999.6), Locale.US)).isEqualTo("1.0 km");
+ assertThat(metric.toDistanceString(kilometers(2.0), Locale.US)).isEqualTo("2.0 km");
+ assertThat(metric.toDistanceString(kilometers(12.4), Locale.US)).isEqualTo("12 km");
assertThat(metric.toElevationString(meters(4401.2), Locale.GERMANY)).isEqualTo("4.401 m");
// Formatting via top-level Units static methods
- assertThat(toDistanceString(km(2.0), Locale.US)).isEqualTo("1.2 mi");
- assertThat(toDistanceString(km(2.0), Locale.FRANCE)).isEqualTo("2,0 km");
- assertThat(toDistanceString(km(2.0), metric, NumberFormatSymbols.DEFAULT)).isEqualTo("2.0 km");
+ assertThat(toDistanceString(kilometers(2.0), Locale.US)).isEqualTo("1.2 mi");
+ assertThat(toDistanceString(kilometers(2.0), Locale.FRANCE)).isEqualTo("2,0 km");
+ assertThat(toDistanceString(kilometers(2.0), metric, NumberFormatSymbols.DEFAULT))
+ .isEqualTo("2.0 km");
assertThat(toElevationString(meters(4401.2), Locale.US)).isEqualTo("14,440 ft");
assertThat(toElevationString(meters(4401.2), metric, NumberFormatSymbols.DEFAULT))
.isEqualTo("4,401 m");
- // Formatting with Android Resources from Java
- Context context = ApplicationProvider.getApplicationContext();
- Resources resources = context.getResources();
- assertThat(toDistanceString(km(2.0), resources, metric)).isEqualTo("2.0 km");
- assertThat(toElevationString(meters(4401.2), resources, imperial)).isEqualTo("14,440 ft");
-
// Standalone logical number formatting
assertThat(Units.formatDistanceValue(2.0, Locale.US)).isEqualTo("2.0");
assertThat(Units.formatDistanceValue(1250.4, NumberFormatSymbols.fromLocale(Locale.GERMANY)))
.isEqualTo("1.250");
+ assertThat(NumberFormatSymbols.DEFAULT.formatFixedDecimal(1234.5, 2)).isEqualTo("1,234.50");
+ assertThat(NumberFormatSymbols.DEFAULT.formatFixedDecimal(1234.5)).isEqualTo("1,235");
+ assertThat(NumberFormatSymbols.DEFAULT.formatDistanceValue(2.0)).isEqualTo("2.0");
+ }
+
+ @Test
+ public void customUnitsConverter_worksFromJavaWithConvertersAndFormatters() {
+ // 1. UK (GB) defaults out-of-the-box to Imperial distances (ft/mi) + Metric elevations (m)
+ UnitsConverter ukDefault = Units.getUnitsConverter(Locale.UK);
+ assertThat(ukDefault.toDistanceString(kilometers(2.0), Locale.UK)).isEqualTo("1.2 mi");
+ assertThat(ukDefault.toElevationString(meters(1345.0), Locale.UK)).isEqualTo("1,345 m");
+
+ // 2. Simple overrides for either distance or elevation from Java
+ UnitsConverter ukWithFeetElevation = ukDefault.withElevationUnits(UnitSystem.IMPERIAL);
+ assertThat(ukWithFeetElevation).isSameInstanceAs(ImperialUnitsConverter.INSTANCE);
+ assertThat(ukWithFeetElevation.toElevationString(meters(1345.0), Locale.UK))
+ .isEqualTo("4,413 ft");
+
+ UnitsConverter ukWithMetricDistance = ukDefault.withDistanceUnits(UnitSystem.METRIC);
+ assertThat(ukWithMetricDistance).isSameInstanceAs(MetricUnitsConverter.INSTANCE);
+ assertThat(ukWithMetricDistance.toDistanceString(kilometers(2.0), Locale.UK))
+ .isEqualTo("2.0 km");
+
+ UnitsConverter fromUnitSystems = UnitsConverter.create(UnitSystem.IMPERIAL, UnitSystem.METRIC);
+ assertThat(fromUnitSystems).isSameInstanceAs(ukDefault);
+
+ // 3. Mix & match existing converters explicitly
+ UnitsConverter ukConverter =
+ UnitsConverter.create(ImperialUnitsConverter.INSTANCE, MetricUnitsConverter.INSTANCE);
+ assertThat(ukConverter.toDistanceString(kilometers(2.0), Locale.UK)).isEqualTo("1.2 mi");
+ assertThat(ukConverter.toElevationString(meters(1345.0), Locale.UK)).isEqualTo("1,345 m");
+
+ // 2. Custom MeasurementFormatter lambdas and method references from Java
+ UnitsConverter customConverter =
+ UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString);
+ assertThat(customConverter.toDistanceString(meters(1500), Locale.US)).isEqualTo("1.50 km");
+ assertThat(customConverter.toElevationString(meters(4401.2), Locale.US)).isEqualTo("4,401 m");
+ }
+
+ @Test
+ public void resourcesOverloads_andLocalizedTranslations_workFromJava() {
+ Context app = RuntimeEnvironment.getApplication();
+ Configuration jaConfig = new Configuration(app.getResources().getConfiguration());
+ jaConfig.setLocale(Locale.JAPAN);
+ Resources jaResources = app.createConfigurationContext(jaConfig).getResources();
+
+ assertThat(UnitSystem.fromResources(jaResources)).isEqualTo(UnitSystem.METRIC);
+ assertThat(toDistanceString(meters(450), jaResources)).isEqualTo("450 m");
+ assertThat(toDistanceString(miles(2.5), jaResources, ImperialUnitsConverter.INSTANCE))
+ .isEqualTo("2.5 マイル");
+ assertThat(toElevationString(meters(4401.2), jaResources, ImperialUnitsConverter.INSTANCE))
+ .isEqualTo("14,440 フィート");
}
}
diff --git a/library/src/test/java/com/google/maps/android/UnitsTest.kt b/library/src/test/java/com/google/maps/android/UnitsTest.kt
index c28936c0f..b653ab705 100644
--- a/library/src/test/java/com/google/maps/android/UnitsTest.kt
+++ b/library/src/test/java/com/google/maps/android/UnitsTest.kt
@@ -15,15 +15,18 @@
*/
package com.google.maps.android
-import android.content.Context
+import android.content.res.Configuration
import android.content.res.Resources
-import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import io.mockk.every
-import io.mockk.mockk
+import io.mockk.mockkStatic
+import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import java.text.DecimalFormat
+import java.text.NumberFormat
import java.util.Locale
/**
@@ -49,31 +52,25 @@ class UnitsTest {
@Test
fun numberExtensions_constructMetersAccurately() {
- assertThat(10.meters).isEqualTo(10.m)
assertThat(10.meters.value).isEqualTo(10.0)
-
- assertThat(1.km).isEqualTo(1_000.m)
+ assertThat(1.kilometers).isEqualTo(1_000.meters)
assertThat(2.5.kilometers).isEqualTo(2_500.meters)
-
assertThat(10.feet.value).isWithin(1e-9).of(3.048)
- assertThat(10.ft).isEqualTo(10.feet)
-
assertThat(1.miles.value).isWithin(1e-9).of(1609.344)
- assertThat(1.mi).isEqualTo(1.miles)
assertThat(5280.feet.value).isWithin(1e-9).of(1.miles.value)
}
@Test
fun metersExtensions_convertToScalarUnitsAccurately() {
- assertThat(1500.meters.toMeters).isEqualTo(1500.0)
- assertThat(1234.m.toKilometers).isWithin(1e-6).of(1.234)
- assertThat(300.meters.toFeet).isWithin(1e-4).of(984.252)
- assertThat(10.km.toMiles).isWithin(1e-4).of(6.21371)
+ assertThat(1500.meters.inMeters).isEqualTo(1500.0)
+ assertThat(1234.meters.inKilometers).isWithin(1e-6).of(1.234)
+ assertThat(300.meters.inFeet).isWithin(1e-4).of(984.252)
+ assertThat(10.kilometers.inMiles).isWithin(1e-4).of(6.21371)
// Exact round-trip conversions
- assertThat(1.miles.toMiles).isWithin(1e-12).of(1.0)
- assertThat(1.feet.toFeet).isWithin(1e-12).of(1.0)
- assertThat(5280.feet.toMiles).isWithin(1e-12).of(1.0)
+ assertThat(1.miles.inMiles).isWithin(1e-12).of(1.0)
+ assertThat(1.feet.inFeet).isWithin(1e-12).of(1.0)
+ assertThat(5280.feet.inMiles).isWithin(1e-12).of(1.0)
}
@Test
@@ -83,7 +80,7 @@ class UnitsTest {
assertThat(shortDistance).isLessThan(longDistance)
assertThat(longDistance).isGreaterThan(shortDistance)
- assertThat(100.meters).isEquivalentAccordingToCompareTo(100.m)
+ assertThat(100.meters).isEquivalentAccordingToCompareTo(Meters(100.0))
assertThat(longDistance + shortDistance).isEqualTo(600.meters)
assertThat(longDistance - shortDistance).isEqualTo(400.meters)
@@ -98,35 +95,93 @@ class UnitsTest {
// =============================================================================================
@Test
- fun unitSystem_fromCountryCode_returnsImperialForUsLrMmAndGb() {
- for (code in listOf("US", "us", "LR", "lr", "MM", "mm", "GB", "gb")) {
+ fun unitSystem_fromCountryCode_returnsImperialForUsLrMm_andMixedForGb() {
+ for (code in listOf("US", "us", "LR", "lr", "MM", "mm")) {
assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
assertThat(getUnitsConverter(code)).isSameInstanceAs(ImperialUnitsConverter)
}
+ for (code in listOf("GB", "gb")) {
+ assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ val gbConverter = getUnitsConverter(code)
+ assertThat(gbConverter.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(gbConverter.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+ }
}
@Test
fun unitSystem_fromCountryCode_returnsMetricForOtherCountriesAndNullOrBlank() {
for (code in listOf("CA", "FR", "DE", "IN", "JP", "AU", "BR", "", " ", null)) {
assertThat(UnitSystem.fromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.distanceFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.elevationFromCountryCode(code)).isEqualTo(UnitSystem.METRIC)
assertThat(getUnitsConverter(code)).isSameInstanceAs(MetricUnitsConverter)
}
}
@Test
- fun unitSystem_fromLocale_resolvesAppropriateUnitSystem() {
+ fun unitSystem_fromLocale_resolvesAppropriateDistanceAndElevationUnitSystems() {
assertThat(UnitSystem.fromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(Locale.US)).isEqualTo(UnitSystem.IMPERIAL)
+
+ // GB uses Imperial for road distances (ft/mi) and Metric for elevations (m)
assertThat(UnitSystem.fromLocale(Locale.UK)).isEqualTo(UnitSystem.IMPERIAL)
- assertThat(UnitSystem.fromLocale(Locale.Builder().setLanguage("en").setRegion("LR").build()))
- .isEqualTo(UnitSystem.IMPERIAL)
- assertThat(UnitSystem.fromLocale(Locale.Builder().setLanguage("my").setRegion("MM").build()))
- .isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.distanceFromLocale(Locale.UK)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(Locale.UK)).isEqualTo(UnitSystem.METRIC)
+ assertThat(2_000.meters.toDistanceString(Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(4401.2.meters.toElevationString(Locale.UK)).isEqualTo("4,401 m")
+
+ val lrLocale = Locale.Builder().setLanguage("en").setRegion("LR").build()
+ assertThat(UnitSystem.distanceFromLocale(lrLocale)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(lrLocale)).isEqualTo(UnitSystem.IMPERIAL)
+
+ val mmLocale = Locale.Builder().setLanguage("my").setRegion("MM").build()
+ assertThat(UnitSystem.distanceFromLocale(mmLocale)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromLocale(mmLocale)).isEqualTo(UnitSystem.IMPERIAL)
assertThat(UnitSystem.fromLocale(Locale.CANADA)).isEqualTo(UnitSystem.METRIC)
- assertThat(UnitSystem.fromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.distanceFromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
+ assertThat(UnitSystem.elevationFromLocale(Locale.FRANCE)).isEqualTo(UnitSystem.METRIC)
assertThat(UnitSystem.fromLocale(Locale.JAPAN)).isEqualTo(UnitSystem.METRIC)
}
+ @Test
+ fun preferredUnitsOverrides_changeDistanceOrElevationUnitsSimply() {
+ // 1. Overriding elevation or distance on a locale-resolved converter via withElevationUnits / withDistanceUnits
+ val ukDefault = getUnitsConverter(Locale.UK)
+ assertThat(ukDefault.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukDefault.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
+ val ukWithFeetElevation = ukDefault.withElevationUnits(UnitSystem.IMPERIAL)
+ assertThat(ukWithFeetElevation).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(ukWithFeetElevation.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("1.2 mi")
+ assertThat(ukWithFeetElevation.toElevationString(1_345.meters, Locale.UK)).isEqualTo("4,413 ft")
+
+ val ukWithMetricDistance = ukDefault.withDistanceUnits(UnitSystem.METRIC)
+ assertThat(ukWithMetricDistance).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(ukWithMetricDistance.toDistanceString(2_000.meters, Locale.UK)).isEqualTo("2.0 km")
+ assertThat(ukWithMetricDistance.toElevationString(1_345.meters, Locale.UK)).isEqualTo("1,345 m")
+
+ // 2. Overriding distance or elevation directly in getUnitsConverter(...)
+ val usWithMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+ assertThat(usWithMetricElevation.toDistanceString(2_000.meters, Locale.US)).isEqualTo("1.2 mi")
+ assertThat(usWithMetricElevation.toElevationString(4401.2.meters, Locale.US)).isEqualTo("4,401 m")
+
+ val deWithImperialDistance = getUnitsConverter(Locale.GERMANY, distanceUnits = UnitSystem.IMPERIAL)
+ assertThat(deWithImperialDistance.toDistanceString(2_000.meters, Locale.GERMANY)).isEqualTo("1,2 mi")
+ assertThat(deWithImperialDistance.toElevationString(4401.2.meters, Locale.GERMANY)).isEqualTo("4.401 m")
+
+ // 3. Constructing directly from (distanceUnits, elevationUnits)
+ val hybrid = UnitsConverter(distanceUnits = UnitSystem.IMPERIAL, elevationUnits = UnitSystem.METRIC)
+ assertThat(hybrid).isSameInstanceAs(ukDefault)
+ assertThat(UnitsConverter(UnitSystem.METRIC, UnitSystem.METRIC)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(UnitsConverter(UnitSystem.IMPERIAL, UnitSystem.IMPERIAL)).isSameInstanceAs(ImperialUnitsConverter)
+ }
+
@Test
fun getUnitsConverter_defaultsToDeviceLocale() {
val originalLocale = Locale.getDefault()
@@ -161,24 +216,19 @@ class UnitsTest {
assertThat(MetricUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("100 m")
assertThat(MetricUnitsConverter.toDistanceString(450.meters, Locale.US)).isEqualTo("450 m")
assertThat(MetricUnitsConverter.toDistanceString(999.meters, Locale.US)).isEqualTo("999 m")
-
- val template = MetricUnitsConverter.toDistanceUnits(450.meters)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_meters)
- assertThat(template.value).isEqualTo(450.0)
+ assertThat(MetricUnitsConverter.toDistanceString(999.4.meters, Locale.US)).isEqualTo("999 m")
}
@Test
fun metricDistance_fromOneToUnderTenKilometers_keepsOneDecimalWithTrailingZero() {
+ // 999.6 m rounds to 1,000 m, so it must switch to the kilometers branch ("1.0 km") rather than "1,000 m"
+ assertThat(MetricUnitsConverter.toDistanceString(999.6.meters, Locale.US)).isEqualTo("1.0 km")
assertThat(MetricUnitsConverter.toDistanceString(1_000.meters, Locale.US)).isEqualTo("1.0 km")
assertThat(MetricUnitsConverter.toDistanceString(1_010.meters, Locale.US)).isEqualTo("1.0 km")
assertThat(MetricUnitsConverter.toDistanceString(1_500.meters, Locale.US)).isEqualTo("1.5 km")
assertThat(MetricUnitsConverter.toDistanceString(1_900.meters, Locale.US)).isEqualTo("1.9 km")
assertThat(MetricUnitsConverter.toDistanceString(2_000.meters, Locale.US)).isEqualTo("2.0 km")
assertThat(MetricUnitsConverter.toDistanceString(9_900.meters, Locale.US)).isEqualTo("9.9 km")
-
- val template = MetricUnitsConverter.toDistanceUnits(2_000.meters)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_kilometers)
- assertThat(template.value).isEqualTo(2.0)
}
@Test
@@ -188,11 +238,7 @@ class UnitsTest {
assertThat(MetricUnitsConverter.toDistanceString(10_000.meters, Locale.US)).isEqualTo("10 km")
assertThat(MetricUnitsConverter.toDistanceString(10_400.meters, Locale.US)).isEqualTo("10 km")
assertThat(MetricUnitsConverter.toDistanceString(11_600.meters, Locale.US)).isEqualTo("12 km")
- assertThat(MetricUnitsConverter.toDistanceString(1_250.km, Locale.US)).isEqualTo("1,250 km")
-
- val template = MetricUnitsConverter.toDistanceUnits(10_400.meters)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_kilometers_whole)
- assertThat(template.value).isEqualTo(10.4)
+ assertThat(MetricUnitsConverter.toDistanceString(1_250.kilometers, Locale.US)).isEqualTo("1,250 km")
}
// =============================================================================================
@@ -205,14 +251,13 @@ class UnitsTest {
assertThat(ImperialUnitsConverter.toDistanceString(100.meters, Locale.US)).isEqualTo("328 ft")
assertThat(ImperialUnitsConverter.toDistanceString(0.1.miles, Locale.US)).isEqualTo("528 ft")
assertThat(ImperialUnitsConverter.toDistanceString(1_319.feet, Locale.US)).isEqualTo("1,319 ft")
-
- val template = ImperialUnitsConverter.toDistanceUnits(0.1.miles)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_feet)
- assertThat(template.value).isWithin(0.01).of(528.0)
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.4.feet, Locale.US)).isEqualTo("1,319 ft")
}
@Test
fun imperialDistance_fromQuarterMileToUnderTenMiles_keepsOneDecimalWithTrailingZero() {
+ // 1,319.6 ft rounds to 1,320 ft (0.25 mi), so it must switch to miles ("0.3 mi") rather than "1,320 ft"
+ assertThat(ImperialUnitsConverter.toDistanceString(1_319.6.feet, Locale.US)).isEqualTo("0.3 mi")
// Exactly 0.25 miles (1,320 feet) switches to miles ("0.3 mi" at 1 decimal place)
assertThat(ImperialUnitsConverter.toDistanceString(0.25.miles, Locale.US)).isEqualTo("0.3 mi")
assertThat(ImperialUnitsConverter.toDistanceString(0.5.miles, Locale.US)).isEqualTo("0.5 mi")
@@ -221,10 +266,6 @@ class UnitsTest {
assertThat(ImperialUnitsConverter.toDistanceString(3_200.meters, Locale.US)).isEqualTo("2.0 mi")
assertThat(ImperialUnitsConverter.toDistanceString(2.5.miles, Locale.US)).isEqualTo("2.5 mi")
assertThat(ImperialUnitsConverter.toDistanceString(9.9.miles, Locale.US)).isEqualTo("9.9 mi")
-
- val template = ImperialUnitsConverter.toDistanceUnits(2.5.miles)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_miles)
- assertThat(template.value).isWithin(1e-9).of(2.5)
}
@Test
@@ -234,10 +275,6 @@ class UnitsTest {
assertThat(ImperialUnitsConverter.toDistanceString(16_100.meters, Locale.US)).isEqualTo("10 mi")
assertThat(ImperialUnitsConverter.toDistanceString(25.6.miles, Locale.US)).isEqualTo("26 mi")
assertThat(ImperialUnitsConverter.toDistanceString(1_250.miles, Locale.US)).isEqualTo("1,250 mi")
-
- val template = ImperialUnitsConverter.toDistanceUnits(10.0.miles)
- assertThat(template.unitsTemplate).isEqualTo(R.string.amu_in_miles_whole)
- assertThat(template.value).isWithin(1e-9).of(10.0)
}
// =============================================================================================
@@ -272,39 +309,9 @@ class UnitsTest {
}
// =============================================================================================
- // § 6. Android `Resources`, Extension Functions, & Raw `Double` Overloads
+ // § 6. Extension Functions, Raw `Double` Overloads, & `NumberFormatSymbols`
// =============================================================================================
- @Test
- fun androidResourcesFormatting_matchesPureJvmFormatting() {
- val context = ApplicationProvider.getApplicationContext()
- val resources = context.resources
-
- assertThat(MetricUnitsConverter.toDistanceString(resources, 450.meters)).isEqualTo("450 m")
- assertThat(MetricUnitsConverter.toDistanceString(resources, 2_000.meters)).isEqualTo("2.0 km")
- assertThat(MetricUnitsConverter.toDistanceString(resources, 12_400.meters)).isEqualTo("12 km")
- assertThat(MetricUnitsConverter.toElevationString(resources, 4401.2.meters)).isEqualTo("4,401 m")
-
- assertThat(ImperialUnitsConverter.toDistanceString(resources, 0.1.miles)).isEqualTo("528 ft")
- assertThat(ImperialUnitsConverter.toDistanceString(resources, 2.0.miles)).isEqualTo("2.0 mi")
- assertThat(ImperialUnitsConverter.toDistanceString(resources, 12.4.miles)).isEqualTo("12 mi")
- assertThat(ImperialUnitsConverter.toElevationString(resources, 4401.2.meters)).isEqualTo("14,440 ft")
-
- // Extension functions with Resources
- assertThat(2_000.meters.toDistanceString(resources, MetricUnitsConverter)).isEqualTo("2.0 km")
- assertThat(4401.2.meters.toElevationString(resources, ImperialUnitsConverter)).isEqualTo("14,440 ft")
- }
-
- @Test
- fun mockedResourcesFormatting_invokesExpectedResourceTemplates() {
- val mockResources = mockk()
- every { mockResources.getString(R.string.amu_in_feet, *anyVararg()) } returns "528 ft"
- every { mockResources.getString(R.string.amu_in_kilometers, *anyVararg()) } returns "2.5 km"
-
- assertThat(ImperialUnitsConverter.toDistanceString(mockResources, 0.1.miles)).isEqualTo("528 ft")
- assertThat(MetricUnitsConverter.toDistanceString(mockResources, 2_500.meters)).isEqualTo("2.5 km")
- }
-
@Test
fun metersExtensionsAndDoubleOverloads_formatCorrectly() {
// Extension functions with Locale
@@ -320,14 +327,8 @@ class UnitsTest {
)
// Raw Double overloads (for Java callers)
- val context = ApplicationProvider.getApplicationContext()
- val resources = context.resources
- assertThat(MetricUnitsConverter.toDistanceUnits(2000.0).value).isEqualTo(2.0)
- assertThat(MetricUnitsConverter.toElevationUnits(450.0).value).isEqualTo(450.0)
assertThat(MetricUnitsConverter.toDistanceString(2000.0, Locale.US)).isEqualTo("2.0 km")
assertThat(MetricUnitsConverter.toElevationString(450.0, Locale.US)).isEqualTo("450 m")
- assertThat(MetricUnitsConverter.toDistanceString(resources, 2000.0)).isEqualTo("2.0 km")
- assertThat(MetricUnitsConverter.toElevationString(resources, 450.0)).isEqualTo("450 m")
// Standalone logical number formatter
assertThat(formatDistanceValue(0.25, Locale.US)).isEqualTo("0.3")
@@ -360,5 +361,184 @@ class UnitsTest {
assertThat(ImperialUnitsConverter.toElevationString(4401.2.meters, ungroupedSymbols)).isEqualTo("14440 ft")
assertThat(4401.2.meters.toElevationString(ImperialUnitsConverter, ungroupedSymbols)).isEqualTo("14440 ft")
assertThat(2_500.meters.toDistanceString(MetricUnitsConverter, ungroupedSymbols)).isEqualTo("2.5 km")
+
+ // When a locale's NumberFormat has isGroupingUsed == false, fromLocale must return groupingSize = 0
+ val ungroupedFormat = (NumberFormat.getNumberInstance(Locale.US) as DecimalFormat).apply {
+ isGroupingUsed = false
+ }
+ mockkStatic(NumberFormat::class) {
+ every { NumberFormat.getNumberInstance(Locale.US) } returns ungroupedFormat
+ val symbolsFromUngroupedLocale = NumberFormatSymbols.fromLocale(Locale.US)
+ assertThat(symbolsFromUngroupedLocale.groupingSize).isEqualTo(0)
+ assertThat(ImperialUnitsConverter.toElevationString(4401.2.meters, symbolsFromUngroupedLocale))
+ .isEqualTo("14440 ft")
+ }
+ }
+
+ // =============================================================================================
+ // § 7. Public `NumberFormatSymbols` Formatting & Custom / Composite `UnitsConverter`s
+ // =============================================================================================
+
+ @Test
+ fun numberFormatSymbols_formatFixedDecimal_and_formatDistanceValue_supportArbitraryPrecision() {
+ val usSymbols = NumberFormatSymbols.DEFAULT
+ assertThat(usSymbols.formatFixedDecimal(1234.567)).isEqualTo("1,235")
+ assertThat(usSymbols.formatFixedDecimal(1234.567, decimals = 0)).isEqualTo("1,235")
+ assertThat(usSymbols.formatFixedDecimal(1234.567, decimals = 1)).isEqualTo("1,234.6")
+ assertThat(usSymbols.formatFixedDecimal(1234.5, decimals = 2)).isEqualTo("1,234.50")
+ assertThat(usSymbols.formatFixedDecimal(1.006, decimals = 2)).isEqualTo("1.01")
+ assertThat(usSymbols.formatFixedDecimal(1.05, decimals = 2)).isEqualTo("1.05")
+ assertThat(usSymbols.formatFixedDecimal(-0.001, decimals = 2)).isEqualTo("0.00")
+ assertThat(usSymbols.formatFixedDecimal(-42.5, decimals = 1)).isEqualTo("-42.5")
+
+ val deSymbols = NumberFormatSymbols.fromLocale(Locale.GERMANY)
+ assertThat(deSymbols.formatFixedDecimal(1234.5, decimals = 2)).isEqualTo("1.234,50")
+ assertThat(deSymbols.formatDistanceValue(2.0)).isEqualTo("2,0")
+ assertThat(deSymbols.formatDistanceValue(1250.4)).isEqualTo("1.250")
+
+ assertThrows(IllegalArgumentException::class.java) {
+ usSymbols.formatFixedDecimal(10.0, decimals = -1)
+ }
+ }
+
+ @Test
+ fun customUnitsConverter_mixesAndMatchesConvertersAndCustomFormatters() {
+ // 1. Mix & match existing converters (e.g., UK: Imperial road distances + Metric elevations)
+ val ukConverter = UnitsConverter(
+ distanceConverter = ImperialUnitsConverter,
+ elevationConverter = MetricUnitsConverter,
+ )
+ assertThat(2_000.meters.toDistanceString(Locale.UK, ukConverter)).isEqualTo("1.2 mi")
+ assertThat(1_345.meters.toElevationString(Locale.UK, ukConverter)).isEqualTo("1,345 m")
+
+ // 2. Mix converter with a custom formatter lambda
+ val imperialWithAslElevation = UnitsConverter(
+ distanceConverter = ImperialUnitsConverter,
+ elevationFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inMeters, decimals = 1)} m ASL"
+ },
+ )
+ assertThat(imperialWithAslElevation.toDistanceString(0.5.miles, Locale.US)).isEqualTo("0.5 mi")
+ assertThat(imperialWithAslElevation.toElevationString(1344.56.meters, Locale.US))
+ .isEqualTo("1,344.6 m ASL")
+
+ // 3. Custom distance formatter + existing elevation converter
+ val nauticalDistanceWithMetricElevation = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ val nauticalMiles = meters.inMeters / 1852.0
+ "${symbols.formatFixedDecimal(nauticalMiles, decimals = 2)} NM"
+ },
+ elevationConverter = MetricUnitsConverter,
+ )
+ assertThat(nauticalDistanceWithMetricElevation.toDistanceString(1852.meters, Locale.US))
+ .isEqualTo("1.00 NM")
+ assertThat(nauticalDistanceWithMetricElevation.toElevationString(450.meters, Locale.US))
+ .isEqualTo("450 m")
+
+ // 4. Both custom formatters (or method references)
+ val customBoth = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} kilometers"
+ },
+ elevationFormatter = MetricUnitsConverter::toElevationString,
+ )
+ assertThat(customBoth.toDistanceString(1500.meters, Locale.FRANCE))
+ .isEqualTo("1,50 kilometers")
+ assertThat(customBoth.toElevationString(4401.2.meters, Locale.FRANCE))
+ .isEqualTo(
+ "4\u202f401 m".replace('\u202f', NumberFormatSymbols.fromLocale(Locale.FRANCE).groupingSeparator),
+ )
+ }
+
+ // =============================================================================================
+ // § 8. Android `Resources` Overloads & Localized Unit Label Translations
+ // =============================================================================================
+
+ @Test
+ fun resourcesOverloads_resolveUnitSystemAndFormatWithLocalizedStringResources() {
+ val usResources = resourcesForLocale(Locale.US)
+ assertThat(UnitSystem.fromResources(usResources)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(getUnitsConverter(usResources)).isSameInstanceAs(ImperialUnitsConverter)
+ assertThat(0.1.miles.toDistanceString(usResources)).isEqualTo("528 ft")
+ assertThat(2.5.miles.toDistanceString(usResources)).isEqualTo("2.5 mi")
+ assertThat(4401.2.meters.toElevationString(usResources)).isEqualTo("14,440 ft")
+
+ val deResources = resourcesForLocale(Locale.GERMANY)
+ assertThat(UnitSystem.fromResources(deResources)).isEqualTo(UnitSystem.METRIC)
+ assertThat(getUnitsConverter(deResources)).isSameInstanceAs(MetricUnitsConverter)
+ assertThat(2_000.meters.toDistanceString(deResources)).isEqualTo("2,0 km")
+ assertThat(4401.2.meters.toElevationString(deResources)).isEqualTo("4.401 m")
+
+ // UK Resources default out-of-the-box to Imperial road distances + Metric elevations
+ val ukResources = resourcesForLocale(Locale.UK)
+ assertThat(UnitSystem.distanceFromResources(ukResources)).isEqualTo(UnitSystem.IMPERIAL)
+ assertThat(UnitSystem.elevationFromResources(ukResources)).isEqualTo(UnitSystem.METRIC)
+ assertThat(2_000.meters.toDistanceString(ukResources)).isEqualTo("1.2 mi")
+ assertThat(1_345.meters.toElevationString(ukResources)).isEqualTo("1,345 m")
+ assertThat(
+ 1_345.meters.toElevationString(
+ ukResources,
+ getUnitsConverter(ukResources, elevationUnits = UnitSystem.IMPERIAL),
+ ),
+ ).isEqualTo("4,413 ft")
+ }
+
+ @Test
+ fun localizedStringResources_translateUnitLabelsAcrossLanguages() {
+ // Japanese (ja-JP): metric uses "m" / "km", imperial uses "フィート" / "マイル"
+ val jaResources = resourcesForLocale(Locale.JAPAN)
+ assertThat(450.meters.toDistanceString(jaResources)).isEqualTo("450 m")
+ assertThat(2.0.kilometers.toDistanceString(jaResources)).isEqualTo("2.0 km")
+ assertThat(0.1.miles.toDistanceString(jaResources, ImperialUnitsConverter)).isEqualTo("528 フィート")
+ assertThat(2.5.miles.toDistanceString(jaResources, ImperialUnitsConverter)).isEqualTo("2.5 マイル")
+ assertThat(4401.2.meters.toElevationString(jaResources, ImperialUnitsConverter)).isEqualTo("14,440 フィート")
+
+ // Korean (ko-KR): imperial uses "피트" / "마일" (no space per Korean CLDR convention)
+ val koResources = resourcesForLocale(Locale.KOREA)
+ assertThat(450.meters.toDistanceString(koResources)).isEqualTo("450 m")
+ assertThat(2.0.kilometers.toDistanceString(koResources)).isEqualTo("2.0 km")
+ assertThat(0.1.miles.toDistanceString(koResources, ImperialUnitsConverter)).isEqualTo("528피트")
+ assertThat(2.5.miles.toDistanceString(koResources, ImperialUnitsConverter)).isEqualTo("2.5마일")
+
+ // Simplified Chinese (zh-CN): "米", "公里", "英尺", "英里"
+ val zhCnResources = resourcesForLocale(Locale.SIMPLIFIED_CHINESE)
+ assertThat(450.meters.toDistanceString(zhCnResources)).isEqualTo("450 米")
+ assertThat(2.0.kilometers.toDistanceString(zhCnResources)).isEqualTo("2.0 公里")
+ assertThat(0.1.miles.toDistanceString(zhCnResources, ImperialUnitsConverter)).isEqualTo("528 英尺")
+ assertThat(2.5.miles.toDistanceString(zhCnResources, ImperialUnitsConverter)).isEqualTo("2.5 英里")
+
+ // Traditional Chinese (zh-TW): "公尺", "公里", "英尺", "英里"
+ val zhTwResources = resourcesForLocale(Locale.TRADITIONAL_CHINESE)
+ assertThat(450.meters.toDistanceString(zhTwResources)).isEqualTo("450 公尺")
+ assertThat(2.0.kilometers.toDistanceString(zhTwResources)).isEqualTo("2.0 公里")
+
+ // Russian (ru-RU): Cyrillic "м", "км", "фт", "ми" + comma decimal separator
+ val ruLocale = Locale.Builder().setLanguage("ru").setRegion("RU").build()
+ val ruResources = resourcesForLocale(ruLocale)
+ assertThat(450.meters.toDistanceString(ruResources)).isEqualTo("450 м")
+ assertThat(2.0.kilometers.toDistanceString(ruResources)).isEqualTo("2,0 км")
+ assertThat(0.1.miles.toDistanceString(ruResources, ImperialUnitsConverter)).isEqualTo("528 фт")
+ assertThat(2.5.miles.toDistanceString(ruResources, ImperialUnitsConverter)).isEqualTo("2,5 ми")
+
+ // Arabic (ar-EG): "م", "كم", "قدم", "ميل"
+ val arLocale = Locale.Builder().setLanguage("ar").setRegion("EG").build()
+ val arResources = resourcesForLocale(arLocale)
+ assertThat(450.meters.toDistanceString(arResources)).endsWith(" م")
+ assertThat(2.0.kilometers.toDistanceString(arResources)).endsWith(" كم")
+ assertThat(0.1.miles.toDistanceString(arResources, ImperialUnitsConverter)).endsWith(" قدم")
+ assertThat(2.5.miles.toDistanceString(arResources, ImperialUnitsConverter)).endsWith(" ميل")
+
+ // French (fr-FR): feet translated as "pi"
+ val frResources = resourcesForLocale(Locale.FRANCE)
+ assertThat(0.1.miles.toDistanceString(frResources, ImperialUnitsConverter)).isEqualTo("528 pi")
+ assertThat(2.0.kilometers.toDistanceString(frResources)).isEqualTo("2,0 km")
+ }
+
+ private fun resourcesForLocale(locale: Locale): Resources {
+ val baseContext = RuntimeEnvironment.getApplication()
+ val config = Configuration(baseContext.resources.configuration).apply {
+ setLocale(locale)
+ }
+ return baseContext.createConfigurationContext(config).resources
}
}
diff --git a/llm-integration-prompt.md b/llm-integration-prompt.md
index 5dbccd107..b5f647324 100644
--- a/llm-integration-prompt.md
+++ b/llm-integration-prompt.md
@@ -47,7 +47,7 @@ Used for server-client coordinate compression and distance calculations.
* **Distance:** `SphericalUtil.computeDistanceBetween(latLng1, latLng2)`
### Units & Logical Distance/Elevation Formatting
-Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` (`US`, `LR`, `MM`, and `GB` default to `IMPERIAL`; all others default to `METRIC`) or an explicit `UnitSystem` override.
+Used to represent measurements with the type-safe `Meters` inline value class and format distances and elevations in Metric (`m`, `km`) or Imperial (`ft`, `mi`) units based on the device's `Locale` or `Resources` (`US`, `LR`, and `MM` default to `IMPERIAL` for both; `GB` defaults to `IMPERIAL` road distances + `METRIC` elevations; all others default to `METRIC`), or an explicit `UnitSystem` / formatter override.
* **Logical Precision Rules:**
* **Small distances** (`< 1 km` metric or `< 0.25 mi` imperial) and **all elevations** format as whole integers with locale thousands grouping (`"450 m"`, `"528 ft"`, `"14,440 ft"`).
@@ -56,65 +56,116 @@ Used to represent measurements with the type-safe `Meters` inline value class an
* **Kotlin Example:**
```kotlin
+import com.google.maps.android.ImperialUnitsConverter
import com.google.maps.android.Meters
+import com.google.maps.android.MetricUnitsConverter
import com.google.maps.android.UnitSystem
+import com.google.maps.android.UnitsConverter
import com.google.maps.android.feet
import com.google.maps.android.getUnitsConverter
-import com.google.maps.android.km
+import com.google.maps.android.inFeet
+import com.google.maps.android.inKilometers
+import com.google.maps.android.kilometers
import com.google.maps.android.meters
import com.google.maps.android.miles
import com.google.maps.android.sphericalDistance
import com.google.maps.android.toDistanceString
import com.google.maps.android.toElevationString
-import com.google.maps.android.toFeet
+import java.util.Locale
// Construct type-safe Meters and perform arithmetic or unit conversions
val summitElevation: Meters = 4401.2.meters
-val elevationInFeet: Double = summitElevation.toFeet // 14439.6325...
+val elevationInFeet: Double = summitElevation.inFeet // 14439.6325...
val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet
-// Format using the device's default Locale (no Android Context required)
+// 1. Default formatting using device Locale, explicit Locale, or Android Resources
val defaultDistance: String = routeDistance.toDistanceString()
-val defaultElevation: String = summitElevation.toElevationString()
+val localizedDistance: String = routeDistance.toDistanceString(context.resources)
+val ukDistance: String = 1.miles.toDistanceString(Locale.UK) // "1.0 mi"
+val ukElevation: String = 1345.meters.toElevationString(Locale.UK) // "1,345 m"
-// Or resolve an explicit Metric / Imperial converter
+// 2. Override both distance and elevation to Metric or Imperial
val metric = getUnitsConverter(UnitSystem.METRIC)
val imperial = getUnitsConverter(UnitSystem.IMPERIAL)
-val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
-val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
-val mediumDrive = 2.0.km.toDistanceString(metric) // "2.0 km"
-val longTrip = 12.4.km.toDistanceString(metric) // "12 km"
-val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+val shortWalk = 450.meters.toDistanceString(metric) // "450 m"
+val quarterMile = 0.25.miles.toDistanceString(imperial) // "0.3 mi"
+val mediumDrive = 2.0.kilometers.toDistanceString(metric) // "2.0 km"
+val longTrip = 12.4.kilometers.toDistanceString(metric) // "12 km"
+val summit = summitElevation.toElevationString(imperial) // "14,440 ft"
+
+// 3. Override only distance or only elevation
+val usMetricElevation = getUnitsConverter(Locale.US, elevationUnits = UnitSystem.METRIC)
+val ukImperialElevation = getUnitsConverter(Locale.UK).withElevationUnits(UnitSystem.IMPERIAL)
+val hybridBySystem = UnitsConverter(
+ distanceUnits = UnitSystem.IMPERIAL,
+ elevationUnits = UnitSystem.METRIC,
+)
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+val customConverter = UnitsConverter(
+ distanceFormatter = { meters, symbols ->
+ "${symbols.formatFixedDecimal(meters.inKilometers, decimals = 2)} km"
+ },
+ elevationConverter = MetricUnitsConverter,
+)
+val customElevation = getUnitsConverter().withElevationFormatter { meters, symbols ->
+ "${symbols.formatInteger(meters.value)} MASL"
+}
```
* **Java Example:**
```java
import static com.google.maps.android.Units.feet;
import static com.google.maps.android.Units.getUnitsConverter;
-import static com.google.maps.android.Units.km;
+import static com.google.maps.android.Units.inFeet;
+import static com.google.maps.android.Units.inKilometers;
+import static com.google.maps.android.Units.kilometers;
import static com.google.maps.android.Units.meters;
import static com.google.maps.android.Units.miles;
import static com.google.maps.android.Units.toDistanceString;
import static com.google.maps.android.Units.toElevationString;
-import static com.google.maps.android.Units.toFeet;
+import com.google.maps.android.ImperialUnitsConverter;
+import com.google.maps.android.MetricUnitsConverter;
import com.google.maps.android.SphericalUtil;
import com.google.maps.android.UnitSystem;
import com.google.maps.android.UnitsConverter;
+import java.util.Locale;
// Express measurements in meters and convert between units
-double twelveFeetInMeters = feet(12); // 3.6576 m
-double elevationInFeet = toFeet(meters(4401.2)); // 14439.63 ft
+double twelveFeetInMeters = feet(12); // 3.6576 m
+double elevationInFeet = inFeet(meters(4401.2)); // 14439.63 ft
-// Format distances and elevations using device default or explicit UnitSystem
+// 1. Default formatting (Locale or Android Resources)
double distanceMeters = SphericalUtil.computeDistanceBetween(pointA, pointB);
String defaultFormatted = toDistanceString(distanceMeters);
+String localizedFormatted = toDistanceString(distanceMeters, context.getResources());
+String ukElevation = toElevationString(meters(1345), Locale.UK); // "1,345 m"
+// 2. Override both distance and elevation
UnitsConverter imperial = getUnitsConverter(UnitSystem.IMPERIAL);
-String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
-String mediumDrive = imperial.toDistanceString(km(2.0)); // "1.2 mi"
-String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+String quarterMile = imperial.toDistanceString(miles(0.25)); // "0.3 mi"
+String mediumDrive = imperial.toDistanceString(kilometers(2.0)); // "1.2 mi"
+String summit = imperial.toElevationString(meters(4401.2)); // "14,440 ft"
+
+// 3. Override only distance or only elevation
+UnitsConverter usMetricElevation = getUnitsConverter(Locale.US)
+ .withElevationUnits(UnitSystem.METRIC);
+UnitsConverter hybridBySystem = UnitsConverter.create(
+ UnitSystem.IMPERIAL,
+ UnitSystem.METRIC
+);
+
+// 4. Mix & match converters or supply custom MeasurementFormatter lambdas
+UnitsConverter ukConverter = UnitsConverter.create(
+ ImperialUnitsConverter.INSTANCE,
+ MetricUnitsConverter.INSTANCE
+);
+UnitsConverter customConverter = UnitsConverter.create(
+ (meters, symbols) -> symbols.formatFixedDecimal(inKilometers(meters), 2) + " km",
+ MetricUnitsConverter.INSTANCE::toElevationString
+);
```
## 3. Best Practices