diff --git a/.gemini/skills/android-maps-utils/SKILL.md b/.gemini/skills/android-maps-utils/SKILL.md index 38981aed1..9c072b47f 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,128 @@ 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` 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"`). + * **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.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.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 java.util.Locale + +// Construct type-safe Meters and perform arithmetic or unit conversions +val summitElevation: Meters = 4401.2.meters +val elevationInFeet: Double = summitElevation.inFeet // 14439.6325... +val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet + +// 1. Default formatting using device Locale, explicit Locale, or Android Resources +val defaultDistance: String = routeDistance.toDistanceString() +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" + +// 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.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.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 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 = inFeet(meters(4401.2)); // 14439.63 ft + +// 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(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 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/README.md b/README.md index 4508fef6d..124531c38 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,128 @@ 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, locale- and `Resources`-aware `UnitSystem` resolution, and logical significant-digit formatting for distances and elevations: + +- **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.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 + +// 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) + +// 2. Override both distance and elevation to Metric or Imperial +val imperial = getUnitsConverter(UnitSystem.IMPERIAL) +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.*;`): + +```java +import static com.google.maps.android.Units.feet; +import static com.google.maps.android.Units.getUnitsConverter; +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 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 + +// 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 = 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/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..012a254d7 --- /dev/null +++ b/demo/src/main/java/com/google/maps/android/utils/demo/UnitsDemoActivity.kt @@ -0,0 +1,315 @@ +/* + * 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.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.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 +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.kilometers +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 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( + 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.kilometers, + "Multi-mile (2.5 mi)" to 2.5.miles, + "Long (>= 10 km)" to 12.4.kilometers, + ) + // [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("#1A73E8".toColorInt()) + .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 + findViewById(R.id.map).doOnLayout { + 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 = {}, + ) { + 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 + 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, + preference.resolveSystemSummary(currentLocale), + currentLocale.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(currentResources, converter), + ) + SummaryRow( + label = "Elevation Delta (${peaks[0].name} → ${peaks.last().name})", + value = elevationDifference.toElevationString(currentResources, converter), + ) + SummaryRow( + label = "Leg 1 (${peaks[0].name} → ${peaks[1].name})", + value = firstLegDistance.toDistanceString(currentResources, converter), + ) + SummaryRow( + label = "Total Geodesic Route", + value = totalRouteDistance.toDistanceString(currentResources, 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(currentResources, 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..2e2cc164a --- /dev/null +++ b/demo/src/test/java/com/google/maps/android/utils/demo/UnitsDemoTest.kt @@ -0,0 +1,50 @@ +/* + * 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.UnitSystem +import com.google.maps.android.meters +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) + + // 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) + + 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..9cd30af43 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -110,6 +110,16 @@ 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 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 { public static final field INSTANCE Lcom/google/maps/android/IndoorBuildingFocusedEvent; } @@ -215,9 +225,77 @@ 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 + 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 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 { } +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 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 + 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 +336,13 @@ 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_meters I + public static field amu_in_miles 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 +448,150 @@ 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 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; + public static fun values ()[Lcom/google/maps/android/UnitSystem; +} + +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 { + 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 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 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 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 meters (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 abstract class com/google/maps/android/UnitsConverter { + public static final field Companion Lcom/google/maps/android/UnitsConverter$Companion; + public fun ()V + 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 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 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 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 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 { 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..509a8c66c --- /dev/null +++ b/library/src/main/java/com/google/maps/android/Units.kt @@ -0,0 +1,1246 @@ +/* + * 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.core.os.ConfigurationCompat +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.inMiles`, `5280.feet.inMiles`) +// 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], [kilometers], [feet], + * [miles]) and convert back to raw scalar units via [inMeters], [inKilometers], [inFeet], and + * [inMiles]. + * + * @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. */ +@get:JvmName("meters") +public inline val Number.meters: 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) + +/** 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) + +/** 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) + +/** Returns the value of this [Meters] measurement in meters as a [Double]. */ +@get:JvmName("inMeters") +public inline val Meters.inMeters: Double + get() = value + +/** Returns the equivalent number of kilometers for this [Meters] measurement. */ +@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("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("inMiles") +public inline val Meters.inMiles: 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: + * 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") + + /** + * 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]. + * + * 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 = 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 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 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 elevationFromCountryCode(countryCode: String?): UnitSystem { + if (countryCode.isNullOrBlank()) return METRIC + return if (countryCode.uppercase(Locale.ROOT) in IMPERIAL_ELEVATION_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, +) { + /** + * 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 + * 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 { + 0 + } + 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"`). + +/** + * Functional (SAM) interface for formatting a measurement in [meters] into a localized string + * using the provided [symbols]. + * + * 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 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 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 { + /** + * 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 abstract fun toDistanceString(meters: Meters, symbols: NumberFormatSymbols): String + + /** + * 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. + * + * @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. + * + * @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] into a localized string using the translated unit templates + * (`R.string.amu_in_*`) and primary locale of the given Android [resources]. + * + * @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 (e.g., `"450 m"`, `"2.0 km"`, `"528 フィート"`, `"2,5 ми"`). + */ + public open fun toDistanceString(resources: Resources, meters: Meters): String = + toDistanceString(meters, NumberFormatSymbols.fromLocale(primaryLocale(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 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. + */ + 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 abstract fun toElevationString(meters: Meters, symbols: NumberFormatSymbols): String + + /** + * 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. + * + * @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. + * + * @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] into a localized string using the translated unit templates + * (`R.string.amu_in_*`) and primary locale of the given Android [resources]. + * + * @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 (e.g., `"4,401 m"`, `"14,440 ft"`, `"14,440 フィート"`). + */ + public open fun toElevationString(resources: Resources, meters: Meters): String = + toElevationString(meters, NumberFormatSymbols.fromLocale(primaryLocale(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 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] + +/** + * Renders distance and elevation measurements in Imperial units (`ft` and `mi`). + * + * - **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 + + /** The [UnitSystem] implemented by this converter ([UnitSystem.IMPERIAL]). */ + public val unitSystem: UnitSystem = UnitSystem.IMPERIAL + + 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 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)) + } + } +} + +/** + * Renders distance and elevation measurements in Metric units (`m` and `km`). + * + * - **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"`). + * - **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. */ + @get:JvmName("getKilometersThreshold") + public val KILOMETERS_THRESHOLD: Meters = METERS_PER_KILOMETER.meters + + /** 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)) + } + + 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 { + formatKilometers(symbols.formatDistanceValue(meters.inKilometers)) + } + } +} +// [END maps_android_utils_units_converters] + +// ================================================================================================= +// § 5. Top-Level Factory & Extension Functions +// ================================================================================================= + +/** + * 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]. + * + * 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( + 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], + * 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`. + * @param distanceUnits Preferred [UnitSystem] for distances (defaults to [UnitSystem.distanceFromCountryCode]). + * @param elevationUnits Preferred [UnitSystem] for elevations (defaults to [UnitSystem.elevationFromCountryCode]). + * @return The resolved [UnitsConverter]. + */ +@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] for both distances + * and elevations. + * + * @param unitSystem The desired [UnitSystem] for both distances and elevations. + * @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. + */ +@JvmName("toDistanceString") +@JvmOverloads +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. + */ +@JvmName("toDistanceString") +@JvmOverloads +public fun Meters.toDistanceString( + converter: UnitsConverter, + symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(), +): String = converter.toDistanceString(this, symbols) + +/** + * 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 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(resources), +): 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. + */ +@JvmName("toElevationString") +@JvmOverloads +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. + */ +@JvmName("toElevationString") +@JvmOverloads +public fun Meters.toElevationString( + converter: UnitsConverter, + symbols: NumberFormatSymbols = NumberFormatSymbols.fromLocale(), +): String = converter.toElevationString(this, symbols) + +/** + * 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 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(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] + +/** + * 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 + +/** + * 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): 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 = scaled / factor + 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 % factor).toString().padStart(decimals, '0')) + } + } +} + +/** + * Formats a non-negative integer [value] with [groupingSeparator] inserted every [groupingSize] + * digits from the right. + */ +internal fun formatGroupedInteger(value: Long, groupingSeparator: Char, groupingSize: Int): 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] 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 new file mode 100644 index 000000000..6fefcc380 --- /dev/null +++ b/library/src/main/res/values/strings.xml @@ -0,0 +1,37 @@ + + + + + %1$s ft + + + %1$s mi + + + %1$s m + + + %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 new file mode 100644 index 000000000..d88beed48 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/UnitsJavaTest.java @@ -0,0 +1,174 @@ +/* + * 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.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.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 android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +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 + * 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(inFeet(twelveFeetInMeters)).isWithin(1e-9).of(12.0); + + double twoAndHalfKmInMeters = kilometers(2.5); + assertThat(twoAndHalfKmInMeters).isEqualTo(2_500.0); + 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(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(inMeters(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); + + // 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 + 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(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(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(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"); + + // 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 new file mode 100644 index 000000000..b653ab705 --- /dev/null +++ b/library/src/test/java/com/google/maps/android/UnitsTest.kt @@ -0,0 +1,544 @@ +/* + * 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.res.Configuration +import android.content.res.Resources +import com.google.common.truth.Truth.assertThat +import io.mockk.every +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 + +/** + * 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.value).isEqualTo(10.0) + 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(1.miles.value).isWithin(1e-9).of(1609.344) + assertThat(5280.feet.value).isWithin(1e-9).of(1.miles.value) + } + + @Test + fun metersExtensions_convertToScalarUnitsAccurately() { + 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.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 + fun meters_arithmeticAndComparisons_behaveCorrectly() { + val shortDistance = 100.meters + val longDistance = 500.meters + + assertThat(shortDistance).isLessThan(longDistance) + assertThat(longDistance).isGreaterThan(shortDistance) + assertThat(100.meters).isEquivalentAccordingToCompareTo(Meters(100.0)) + + 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_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_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.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.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() + 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") + 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") + } + + @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.kilometers, Locale.US)).isEqualTo("1,250 km") + } + + // ============================================================================================= + // § 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") + 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") + 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") + } + + @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") + } + + // ============================================================================================= + // § 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. Extension Functions, Raw `Double` Overloads, & `NumberFormatSymbols` + // ============================================================================================= + + @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) + assertThat(MetricUnitsConverter.toDistanceString(2000.0, Locale.US)).isEqualTo("2.0 km") + assertThat(MetricUnitsConverter.toElevationString(450.0, Locale.US)).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") + + // 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 176e85a23..b5f647324 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,6 +46,128 @@ 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` 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"`). + * **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.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.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 java.util.Locale + +// Construct type-safe Meters and perform arithmetic or unit conversions +val summitElevation: Meters = 4401.2.meters +val elevationInFeet: Double = summitElevation.inFeet // 14439.6325... +val routeDistance: Meters = startLatLng.sphericalDistance(endLatLng).meters + 500.feet + +// 1. Default formatting using device Locale, explicit Locale, or Android Resources +val defaultDistance: String = routeDistance.toDistanceString() +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" + +// 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.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.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 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 = inFeet(meters(4401.2)); // 14439.63 ft + +// 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(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 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`.