diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec910a67..ceba2360 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,4 +71,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot"} +screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot"} \ No newline at end of file diff --git a/maps-compose-widgets/build.gradle.kts b/maps-compose-widgets/build.gradle.kts index 8ec76cea..726f8c53 100644 --- a/maps-compose-widgets/build.gradle.kts +++ b/maps-compose-widgets/build.gradle.kts @@ -11,11 +11,19 @@ android { sarifOutput = layout.buildDirectory.file("reports/lint-results.sarif").get().asFile } + packaging { + resources { + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } + namespace = "com.google.maps.android.compose.widgets" compileSdk = 36 defaultConfig { minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -55,4 +63,6 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.test.espresso) androidTestImplementation(libs.androidx.test.junit.ktx) + androidTestImplementation(libs.mockk) + androidTestImplementation(libs.truth) } diff --git a/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt new file mode 100644 index 00000000..8ac332fe --- /dev/null +++ b/maps-compose-widgets/src/androidTest/java/com/google/maps/android/compose/ScaleBarUnitTest.kt @@ -0,0 +1,43 @@ +package com.google.maps.android.compose.widgets + + +import android.graphics.Point +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import com.google.common.truth.Truth.assertThat +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.maps.Projection +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.ktx.utils.sphericalDistance +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +public class ScaleBarUnitTest { + + @Test + public fun testScaleBarCalculation() { + val projection = mockk(relaxed = true) + val density = Density(1f, 1f) + val width = 100.dp + + val startPoint = Point(0, 0) + val endPoint = Point(width.value.toInt(), 0) + + val startLatLng = LatLng(0.0, 0.0) + val endLatLng = LatLng(0.0, 0.001) + + every { projection.fromScreenLocation(startPoint) } returns startLatLng + every { projection.fromScreenLocation(endPoint) } returns endLatLng + + val expectedDistance = startLatLng.sphericalDistance(endLatLng) + val expectedResult = (expectedDistance * 8 / 9).toInt() + + val result = calculateDistance(projection, width, density) + + assertThat(result).isEqualTo(expectedResult) + } +} 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 afa46463..19688781 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 @@ -40,15 +40,36 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import com.google.android.gms.maps.Projection import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.ktx.utils.sphericalDistance import kotlinx.coroutines.delay +internal fun calculateDistance( + projection: Projection, + width: Dp, + density: Density +): Int { + val widthInPixels = with(density) { + width.toPx().toInt() + } + + val upperLeftLatLng = projection.fromScreenLocation(Point(0, 0)) + val upperRightLatLng = + projection.fromScreenLocation(Point(widthInPixels, 0)) + + val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) + + return (canvasWidthMeters * 8 / 9).toInt() +} + public val DarkGray: Color = Color(0xFF3a3c3b) private val defaultWidth: Dp = 65.dp private val defaultHeight: Dp = 50.dp @@ -75,37 +96,12 @@ public fun ScaleBar( lineColor: Color = DarkGray, shadowColor: Color = Color.White, ) { - // This is the core logic for calculating the scale of the map. - // - // `remember` with a key (`cameraPositionState.position.zoom`) is used for performance. - // It ensures that the calculation inside is only re-executed when the zoom level changes. - // This is important because we don't need to recalculate the scale every time the map pans, - // only when the zoom level changes. - // - // `derivedStateOf` is a Compose state function that creates a new state object that is - // derived from other state objects. The calculation inside `derivedStateOf` is only - // re-executed when one of the state objects it reads from changes. In this case, it's - // `cameraPositionState.projection`. This is another performance optimization that - // prevents unnecessary recalculations. + val density = LocalDensity.current val horizontalLineWidthMeters by remember(cameraPositionState.position.zoom) { derivedStateOf { - // The projection is used to convert between screen coordinates (pixels) and - // geographical coordinates (LatLng). It can be null if the map is not ready yet. - val projection = cameraPositionState.projection ?: return@derivedStateOf 0 - - // We get the geographical coordinates of two points on the screen: the top-left - // corner (0, 0) and a point to the right of it, at the width of the scale bar. - val upperLeftLatLng = projection.fromScreenLocation(Point(0, 0)) - val upperRightLatLng = - projection.fromScreenLocation(Point(0, width.value.toInt())) - - // We then calculate the spherical distance between these two points in meters. - // This gives us the distance that the scale bar represents on the map. - val canvasWidthMeters = upperLeftLatLng.sphericalDistance(upperRightLatLng) - - // We take 8/9th of the canvas width to provide some padding on the right side - // of the scale bar. - (canvasWidthMeters * 8 / 9).toInt() + cameraPositionState.projection?.let { + calculateDistance(it, width, density) + } ?: 0 } }