diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt new file mode 100644 index 00000000..7f2bb02b --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarRenderingTests.kt @@ -0,0 +1,87 @@ +// 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.compose + +import android.graphics.Point +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.widgets.ScaleBar +import io.mockk.every +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test + +class ScaleBarRenderingTests { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun fractionalLabelsRenderOnLightBackground() { + assertFractionalLabels(dark = false) + } + + @Test + fun fractionalLabelsRenderOnDarkBackground() { + assertFractionalLabels(dark = true) + } + + @Test + fun largeLabelsRenderOnLightBackground() { + assertFractionalLabels(dark = false, large = true) + } + + @Test + fun largeLabelsRenderOnDarkBackground() { + assertFractionalLabels(dark = true, large = true) + } + + private fun assertFractionalLabels(dark: Boolean, large: Boolean = false) { + val projection = mockk() + every { projection.fromScreenLocation(any()) } answers { + // At the equator 0.02 degrees spans about 2,224 m, or 1,976 m for the 8/9-width bar. + if (firstArg().x == 0) LatLng(0.0, 0.0) else LatLng(0.0, if (large) 20.0 else 0.02) + } + val camera = mockk() + every { camera.position } returns CameraPosition.fromLatLngZoom(LatLng(0.0, 0.0), 12f) + every { camera.projection } returns projection + val background = if (dark) Color.Black else Color.White + val foreground = if (dark) Color.White else Color.Black + composeTestRule.setContent { + MaterialTheme { + Box(Modifier.background(background).padding(16.dp)) { + ScaleBar( + cameraPositionState = camera, + textColor = foreground, + lineColor = foreground, + shadowColor = background, + ) + } + } + } + composeTestRule.onNodeWithText(if (large) "1977 km" else "2 km").assertIsDisplayed() + composeTestRule.onNodeWithText(if (large) "1228 mi" else "1.2 mi").assertIsDisplayed() + } +} diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt index 99de1243..3be09312 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/ScaleBarTests.kt @@ -31,6 +31,7 @@ import org.junit.Rule import org.junit.Test import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import kotlin.math.roundToInt // These constants are used for converting between metric and imperial units // to ensure the scale bar displays distances correctly in both systems. @@ -75,7 +76,20 @@ class ScaleBarTests { @Test fun testScaleBarInitialState() { - val initialZoom = 15f + assertScaleBarLabels(initialZoom = 15f) + } + + @Test + fun testScaleBarFractionalKilometers() { + assertScaleBarLabels(initialZoom = 12f) + } + + @Test + fun testScaleBarFractionalMiles() { + assertScaleBarLabels(initialZoom = 10f) + } + + private fun assertScaleBarLabels(initialZoom: Float) { val initialPosition = LatLng(37.7749, -122.4194) // San Francisco initScaleBar(initialZoom, initialPosition) @@ -96,25 +110,21 @@ class ScaleBarTests { } val upperLeftLatLng = proj.fromScreenLocation(Point(0, 0)) - val upperRightLatLng = proj.fromScreenLocation(Point(0, widthInPixels)) + val upperRightLatLng = proj.fromScreenLocation(Point(widthInPixels, 0)) val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) val horizontalLineWidthMeters = (canvasWidthMeters * 8 / 9).toInt() - var metricUnits = "m" - var metricDistance = horizontalLineWidthMeters - if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { - metricUnits = "km" - metricDistance /= METERS_IN_KILOMETER.toInt() + metricText = if (horizontalLineWidthMeters >= METERS_IN_KILOMETER) { + "${formatTestDistance(horizontalLineWidthMeters / METERS_IN_KILOMETER)} km" + } else { + "$horizontalLineWidthMeters m" } - - var imperialUnits = "ft" - var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet() - if (imperialDistance > FEET_IN_MILE) { - imperialUnits = "mi" - imperialDistance = imperialDistance.toMiles() + val feet = horizontalLineWidthMeters.toDouble().toFeet() + imperialText = if (feet >= FEET_IN_MILE) { + "${formatTestDistance(feet.toMiles())} mi" + } else { + "${feet.roundToInt()} ft" } - imperialText = "${imperialDistance.toInt()} $imperialUnits" - metricText = "$metricDistance $metricUnits" } ?: fail("Projection should not be null") } @@ -130,3 +140,9 @@ class ScaleBarTests { internal fun Double.toFeet(): Double = this * CENTIMETERS_IN_METER / CENTIMETERS_IN_INCH / INCHES_IN_FOOT internal fun Double.toMiles(): Double = this / FEET_IN_MILE + +private fun formatTestDistance(distance: Double): String { + if (distance >= 10.0) return distance.roundToInt().toString() + val scaled = (distance * 10.0).roundToInt() + return if (scaled % 10 == 0) "${scaled / 10}" else "${scaled / 10}.${scaled % 10}" +} diff --git a/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt index f1071bdb..428ecd2b 100644 --- a/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt +++ b/maps-compose-widgets/src/main/java/com/google/maps/android/compose/widgets/ScaleBar.kt @@ -51,6 +51,7 @@ import com.google.android.gms.maps.Projection import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.sphericalDistance import kotlinx.coroutines.delay +import kotlin.math.roundToInt internal fun calculateDistance(projection: Projection, width: Dp, density: Density): Int { val widthInPixels = with(density) { @@ -177,43 +178,47 @@ public fun ScaleBar( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceAround, ) { - // Here, we determine the appropriate units (meters/kilometers and feet/miles) - // based on the calculated distance in meters. - - var metricUnits = "m" - var metricDistance = horizontalLineWidthMeters - if (horizontalLineWidthMeters > METERS_IN_KILOMETER) { - // Switch from meters to kilometers as unit - metricUnits = "km" - metricDistance /= METERS_IN_KILOMETER.toInt() - } - - var imperialUnits = "ft" - var imperialDistance = horizontalLineWidthMeters.toDouble().toFeet() - if (imperialDistance > FEET_IN_MILE) { - // Switch from ft to miles as unit - imperialUnits = "mi" - imperialDistance = imperialDistance.toMiles() - } - // We display the calculated distances in two Text composables, one for imperial // and one for metric units. ScaleText( modifier = Modifier.align(End), textColor = textColor, shadowColor = shadowColor, - text = "${imperialDistance.toInt()} $imperialUnits", + text = formatImperialDistance(horizontalLineWidthMeters), ) ScaleText( modifier = Modifier.align(End), textColor = textColor, shadowColor = shadowColor, - text = "$metricDistance $metricUnits", + text = formatMetricDistance(horizontalLineWidthMeters), ) } } } +internal fun formatMetricDistance(meters: Int): String = if (meters >= METERS_IN_KILOMETER) { + "${formatScaleDistance(meters / METERS_IN_KILOMETER)} km" +} else { + "$meters m" +} + +internal fun formatImperialDistance(meters: Int): String { + val feet = meters.toDouble().toFeet() + return if (feet >= FEET_IN_MILE) { + "${formatScaleDistance(feet.toMiles())} mi" + } else { + "${feet.roundToInt()} ft" + } +} + +private fun formatScaleDistance(distance: Double): String { + if (distance >= 10.0) { + return distance.roundToInt().toString() + } + val scaled = (distance * 10.0).roundToInt() + return if (scaled % 10 == 0) "${scaled / 10}" else "${scaled / 10}.${scaled % 10}" +} + /** * An animated scale bar that appears when the zoom level of the map changes, and then disappears * after [visibilityDurationMillis]. This composable wraps [ScaleBar] with visibility animations. diff --git a/maps-compose-widgets/src/test/java/com/google/maps/android/compose/widgets/ScaleBarDistanceTest.kt b/maps-compose-widgets/src/test/java/com/google/maps/android/compose/widgets/ScaleBarDistanceTest.kt new file mode 100644 index 00000000..52b635e7 --- /dev/null +++ b/maps-compose-widgets/src/test/java/com/google/maps/android/compose/widgets/ScaleBarDistanceTest.kt @@ -0,0 +1,67 @@ +// 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.compose.widgets + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class ScaleBarDistanceTest { + @Test + fun metricDistancePreservesFractionalKilometers() { + assertEquals("1.9 km", formatMetricDistance(1900)) + assertEquals("1 km", formatMetricDistance(1010)) + } + + @Test + fun imperialDistancePreservesFractionalMiles() { + assertEquals("1.2 mi", formatImperialDistance(2000)) + assertEquals("2 mi", formatImperialDistance(3200)) + } + + @Test + fun metricDistanceUsesKilometersAtUnitBoundary() { + assertEquals("999 m", formatMetricDistance(999)) + assertEquals("1 km", formatMetricDistance(1000)) + assertEquals("2 km", formatMetricDistance(2000)) + } + + @Test + fun smallDistancesKeepWholeMetersAndFeet() { + assertEquals("0 m", formatMetricDistance(0)) + assertEquals("0 ft", formatImperialDistance(0)) + assertEquals("100 m", formatMetricDistance(100)) + assertEquals("328 ft", formatImperialDistance(100)) + } + + @Test + fun largeDistancesRoundToWholeUnits() { + assertEquals("10 km", formatMetricDistance(10_400)) + assertEquals("12 km", formatMetricDistance(11_600)) + assertEquals("10 mi", formatImperialDistance(16_100)) + assertEquals("3457 km", formatMetricDistance(3_456_780)) + } + + @Test + fun fractionalDistancesTrimZerosAndRoundAtBoundaries() { + assertEquals("1 km", formatMetricDistance(1049)) + assertEquals("1.1 km", formatMetricDistance(1050)) + assertEquals("9.9 km", formatMetricDistance(9949)) + assertEquals("10 km", formatMetricDistance(9950)) + assertEquals("10 km", formatMetricDistance(9999)) + assertEquals("10 km", formatMetricDistance(9990)) + assertEquals("3 ft", formatImperialDistance(1)) + assertEquals("7 ft", formatImperialDistance(2)) + } +}