From 040e2f9b67f41ab0a7da7d8a08b4f89c46aa75ce Mon Sep 17 00:00:00 2001 From: Ash Davies <1892070+ashdavies@users.noreply.github.com> Date: Tue, 13 Feb 2024 23:43:29 +0100 Subject: [PATCH] Create computeable callable (#820) * Create computeable callable * Reduce visibility of error serializable * Remove routing dependency * chore(deps): update terraform google to v5.16.0 (#844) Co-authored-by: playground-manager[bot] <126197455+playground-manager[bot]@users.noreply.github.com> * fix(deps): update slack.circuit to v0.19.1 (#843) * fix(deps): update slack.circuit to v0.19.1 * Adjust saveable back stack with initial screen * Provide fake navigator with initial screen --------- Co-authored-by: playground-manager[bot] <126197455+playground-manager[bot]@users.noreply.github.com> Co-authored-by: Ash Davies <1892070+ashdavies@users.noreply.github.com> * Adjust lat lng state and callback * Move state hoisting to expect function * Commas --------- Co-authored-by: playground-manager[bot] <126197455+playground-manager[bot]@users.noreply.github.com> --- .../io/ashdavies/events/GetEventsCallable.kt | 2 +- gradle/libs.versions.toml | 5 +- map-routes/build.gradle.kts | 15 ++ .../io/ashdavies/routes/RouteMap.android.kt | 39 ++++- .../io/ashdavies/routes/RouteFactory.kt | 6 +- .../kotlin/io/ashdavies/routes/RouteMap.kt | 18 +-- .../io/ashdavies/routes/RoutePresenter.kt | 39 ++++- .../kotlin/io/ashdavies/routes/RouteScreen.kt | 9 +- .../io/ashdavies/routes/RouteMap.jvm.kt | 11 +- maps-routing/build.gradle.kts | 18 +++ .../src/androidMain/AndroidManifest.xml | 2 + .../routing/ComputeRoutesCallable.kt | 141 ++++++++++++++++++ settings.gradle.kts | 1 + 13 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 maps-routing/build.gradle.kts create mode 100644 maps-routing/src/androidMain/AndroidManifest.xml create mode 100644 maps-routing/src/commonMain/kotlin/io/ashdavies/routing/ComputeRoutesCallable.kt diff --git a/after-party/src/commonMain/kotlin/io/ashdavies/events/GetEventsCallable.kt b/after-party/src/commonMain/kotlin/io/ashdavies/events/GetEventsCallable.kt index c99a3fb8f..c93299c02 100644 --- a/after-party/src/commonMain/kotlin/io/ashdavies/events/GetEventsCallable.kt +++ b/after-party/src/commonMain/kotlin/io/ashdavies/events/GetEventsCallable.kt @@ -55,7 +55,7 @@ internal class GetEventsCallable( } @Serializable -public data class GetEventsError( +internal data class GetEventsError( override val message: String, val code: Int, ) : Throwable() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 172eb6473..4604f99f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,6 @@ google-accompanist-placeholderMaterial = { module = "com.google.accompanist:acco google-android-identity = "com.google.android.libraries.identity.googleid:googleid:1.1.0" google-android-location = "com.google.android.gms:play-services-location:21.1.0" -google-android-maps = "com.google.android.gms:play-services-maps:18.2.0" google-android-material = "com.google.android.material:material:1.11.0" google-auth-http = "com.google.auth:google-auth-library-oauth2-http:1.23.0" @@ -64,8 +63,8 @@ google-firebase-appcheck-playintegrity = { module = "com.google.firebase:firebas google-guava-jre = "com.google.guava:guava:33.0.0-jre" -google-maps-android-compose = { module = "com.google.maps.android:maps-compose", version.ref = "google-maps-compose" } -google-maps-android-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "google-maps-compose" } +google-maps-android-compose = "com.google.maps.android:maps-compose:4.3.2" +google-maps-android-utils = "com.google.maps.android:android-maps-utils:3.8.2" kotlinx-cli = "org.jetbrains.kotlinx:kotlinx-cli:0.3.6" kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7" diff --git a/map-routes/build.gradle.kts b/map-routes/build.gradle.kts index 365ff32b7..750918e6c 100644 --- a/map-routes/build.gradle.kts +++ b/map-routes/build.gradle.kts @@ -3,6 +3,8 @@ plugins { id("io.ashdavies.default") id("io.ashdavies.parcelable") id("io.ashdavies.properties") + + alias(libs.plugins.build.config) } android { @@ -15,15 +17,27 @@ android { namespace = "io.ashdavies.routes" } +buildConfig { + val androidApiKey by stringProperty { value -> + buildConfigField("ANDROID_API_KEY", value) + } + + packageName.set(android.namespace) +} + kotlin { commonMain.dependencies { implementation(projects.circuitSupport) + implementation(projects.httpClient) + implementation(projects.httpCommon) + implementation(projects.mapsRouting) implementation(projects.platformSupport) implementation(compose.material3) implementation(compose.runtime) implementation(libs.androidx.annotation) + implementation(libs.ktor.client.core) implementation(libs.slack.circuit.foundation) } @@ -31,6 +45,7 @@ kotlin { implementation(libs.google.accompanist.permissions) implementation(libs.google.android.location) implementation(libs.google.maps.android.compose) + implementation(libs.google.maps.android.utils) implementation(libs.kotlinx.coroutines.play.services) } } diff --git a/map-routes/src/androidMain/kotlin/io/ashdavies/routes/RouteMap.android.kt b/map-routes/src/androidMain/kotlin/io/ashdavies/routes/RouteMap.android.kt index 356e5d6bf..1fc6f13a5 100644 --- a/map-routes/src/androidMain/kotlin/io/ashdavies/routes/RouteMap.android.kt +++ b/map-routes/src/androidMain/kotlin/io/ashdavies/routes/RouteMap.android.kt @@ -9,32 +9,39 @@ import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.PolyUtil import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.MarkerState +import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.rememberCameraPositionState - -public actual typealias LatLng = com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLng as GmsLatLng private const val CAMERA_ANIMATE_DURATION = 2_000 @Composable -internal actual fun RouteMap(state: RouteMapState, modifier: Modifier) { +internal actual fun RouteMap( + state: RouteMapState, + modifier: Modifier, + onEndPosition: (LatLng) -> Unit, +) { val cameraPositionState = rememberCameraPositionState() LaunchedEffect(state.startPosition, state.zoomLevel) { - val cameraPosition = CameraPosition.fromLatLngZoom(state.startPosition, state.zoomLevel) + val startPosition = state.startPosition.asGmsLatLng() + val cameraPosition = CameraPosition.fromLatLngZoom(startPosition, state.zoomLevel) val cameraUpdate = CameraUpdateFactory.newCameraPosition(cameraPosition) + cameraPositionState.animate(cameraUpdate, CAMERA_ANIMATE_DURATION) } GoogleMap( cameraPositionState = cameraPositionState, modifier = modifier.fillMaxSize(), - onMapClick = { state.endPosition = it }, + onMapClick = { onEndPosition(it.asLatLng()) }, ) { Marker( - state = MarkerState(state.startPosition), + state = MarkerState(state.startPosition.asGmsLatLng()), icon = rememberGreenMarker(), title = "Start", ) @@ -42,11 +49,19 @@ internal actual fun RouteMap(state: RouteMapState, modifier: Modifier) { val endPosition = state.endPosition if (endPosition != null) { Marker( - state = MarkerState(endPosition), + state = MarkerState(endPosition.asGmsLatLng()), icon = rememberRedMarker(), title = "End", ) } + + if (state.routes.isNotEmpty()) { + Polyline( + points = state.routes.flatMap { + PolyUtil.decode(it.polyline.encodedPolyline) + }, + ) + } } } @@ -59,3 +74,13 @@ private fun rememberGreenMarker(): BitmapDescriptor = remember { private fun rememberRedMarker(): BitmapDescriptor = remember { BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED) } + +private fun GmsLatLng.asLatLng() = LatLng( + latitude = latitude, + longitude = longitude, +) + +private fun LatLng.asGmsLatLng() = GmsLatLng( + /* latitude = */ latitude, + /* longitude = */ longitude, +) diff --git a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteFactory.kt b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteFactory.kt index b144daebd..5d7c44c76 100644 --- a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteFactory.kt +++ b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteFactory.kt @@ -1,5 +1,6 @@ package io.ashdavies.routes +import androidx.compose.runtime.remember import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.ui.Ui import io.ashdavies.circuit.presenterFactoryOf @@ -7,14 +8,13 @@ import io.ashdavies.circuit.uiFactoryOf import io.ashdavies.content.PlatformContext public fun RoutePresenterFactory(context: PlatformContext): Presenter.Factory { - val locationService = LocationService(context) return presenterFactoryOf { _, _ -> - RoutePresenter(locationService) + RoutePresenter(remember(context) { LocationService(context) }) } } public fun RouteUiFactory(): Ui.Factory { return uiFactoryOf { _, state, modifier -> - RouteScreen(state, modifier) + RouteScreen(state, modifier) { state.eventSink(RouteScreen.Event.OnEndPosition(it)) } } } diff --git a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteMap.kt b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteMap.kt index 30e71f0d5..7d8483e3c 100644 --- a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteMap.kt +++ b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteMap.kt @@ -2,27 +2,25 @@ package io.ashdavies.routes import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import io.ashdavies.routing.ComputeRoutesResponse @Stable internal data class RouteMapState( val startPosition: LatLng = KnownLocations.Berlin, + val endPosition: LatLng? = null, + val routes: List = emptyList(), val zoomLevel: Float = 12f, -) { - - var endPosition by mutableStateOf(null) -} +) @Composable internal expect fun RouteMap( state: RouteMapState, modifier: Modifier = Modifier, + onEndPosition: (LatLng) -> Unit, ) -public expect class LatLng( - latitude: Double, - longitude: Double, +internal data class LatLng( + val latitude: Double, + val longitude: Double, ) diff --git a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RoutePresenter.kt b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RoutePresenter.kt index b33228241..80164f5af 100644 --- a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RoutePresenter.kt +++ b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RoutePresenter.kt @@ -6,9 +6,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import io.ashdavies.http.LocalHttpClient +import io.ashdavies.routing.ComputeRoutesCallable +import io.ashdavies.routing.ComputeRoutesError +import io.ashdavies.routing.ComputeRoutesRequest +import io.ktor.client.HttpClient @Composable -internal fun RoutePresenter(locationService: LocationService): RouteScreen.State { +internal fun RoutePresenter( + locationService: LocationService, + httpClient: HttpClient = LocalHttpClient.current, +): RouteScreen.State { var startPosition by remember { mutableStateOf(KnownLocations.Berlin) } val locationPermissionState = rememberLocationPermissionState() @@ -19,9 +27,32 @@ internal fun RoutePresenter(locationService: LocationService): RouteScreen.State } } + val computeRoutes = remember { ComputeRoutesCallable(httpClient, BuildConfig.ANDROID_API_KEY) } + var mapState by remember { mutableStateOf(RouteMapState(startPosition)) } + var errorMessage = null as String? + + LaunchedEffect(mapState.endPosition) { + val endPosition = mapState.endPosition ?: return@LaunchedEffect + + val computeRoutesRequest = ComputeRoutesRequest( + origin = mapState.startPosition.asComputeRoutesRequestLatLng(), + destination = endPosition.asComputeRoutesRequestLatLng(), + ) + + try { + val computeRoutesResponse = computeRoutes(computeRoutesRequest) + mapState = mapState.copy(routes = computeRoutesResponse.routes) + } catch (exception: ComputeRoutesError) { + errorMessage = exception.message + } + } + return RouteScreen.State( - mapState = RouteMapState( - startPosition = startPosition, - ), + mapState = mapState, + errorMessage = errorMessage, ) { } } + +private fun LatLng.asComputeRoutesRequestLatLng(): ComputeRoutesRequest.LatLng { + return ComputeRoutesRequest.LatLng(latitude, longitude) +} diff --git a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteScreen.kt b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteScreen.kt index 2bfb8f81d..d4705af69 100644 --- a/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteScreen.kt +++ b/map-routes/src/commonMain/kotlin/io/ashdavies/routes/RouteScreen.kt @@ -11,10 +11,13 @@ public fun RouteScreen(): Screen = RouteScreen @Parcelize internal object RouteScreen : Screen { - sealed interface Event : CircuitUiEvent + sealed interface Event : CircuitUiEvent { + data class OnEndPosition(val position: LatLng) : Event + } data class State( - val mapState: RouteMapState, + val mapState: RouteMapState = RouteMapState(), + val errorMessage: String? = null, val eventSink: (Event) -> Unit, ) : CircuitUiState } @@ -23,9 +26,11 @@ internal object RouteScreen : Screen { internal fun RouteScreen( state: RouteScreen.State, modifier: Modifier = Modifier, + onEndPosition: (LatLng) -> Unit, ) { RouteMap( state = state.mapState, modifier = modifier, + onEndPosition = onEndPosition, ) } diff --git a/map-routes/src/jvmMain/kotlin/io/ashdavies/routes/RouteMap.jvm.kt b/map-routes/src/jvmMain/kotlin/io/ashdavies/routes/RouteMap.jvm.kt index 56d98c1aa..c5f605fbd 100644 --- a/map-routes/src/jvmMain/kotlin/io/ashdavies/routes/RouteMap.jvm.kt +++ b/map-routes/src/jvmMain/kotlin/io/ashdavies/routes/RouteMap.jvm.kt @@ -4,12 +4,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -public actual class LatLng actual constructor( - latitude: Double, - longitude: Double, -) - @Composable -internal actual fun RouteMap(state: RouteMapState, modifier: Modifier) { +internal actual fun RouteMap( + state: RouteMapState, + modifier: Modifier, + onEndPosition: (LatLng) -> Unit, +) { Text("Unsupported Platform") } diff --git a/maps-routing/build.gradle.kts b/maps-routing/build.gradle.kts new file mode 100644 index 000000000..8bc5bd1d6 --- /dev/null +++ b/maps-routing/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("io.ashdavies.default") +} + +android { + namespace = "io.ashdavies.routing" +} + +kotlin { + commonMain.dependencies { + implementation(projects.httpClient) + implementation(projects.httpCommon) + + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.datetime) + implementation(libs.ktor.client.core) + } +} diff --git a/maps-routing/src/androidMain/AndroidManifest.xml b/maps-routing/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/maps-routing/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/maps-routing/src/commonMain/kotlin/io/ashdavies/routing/ComputeRoutesCallable.kt b/maps-routing/src/commonMain/kotlin/io/ashdavies/routing/ComputeRoutesCallable.kt new file mode 100644 index 000000000..9c0f790c1 --- /dev/null +++ b/maps-routing/src/commonMain/kotlin/io/ashdavies/routing/ComputeRoutesCallable.kt @@ -0,0 +1,141 @@ +package io.ashdavies.routing + +import io.ashdavies.http.DefaultHttpClient +import io.ashdavies.http.UnaryCallable +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.HttpCallValidator +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable + +private const val ROUTES_GOOGLE_APIS = "https://routes.googleapis.com" + +private const val HEADER_API_KEY = "X-Goog-Api-Key" +private const val HEADER_FIELD_MASK = "X-Goog-FieldMask" + +private const val FIELD_ENCODED_POLYLINE = "routes.distanceMeters,routes.duration,routes.polyline.encodedPolyline" + +public class ComputeRoutesCallable( + httpClient: HttpClient = DefaultHttpClient(), + apiKey: String, +) : UnaryCallable { + + private val httpClient = httpClient.config { + install(DefaultRequest) { + header(HEADER_API_KEY, apiKey) + header(HEADER_FIELD_MASK, FIELD_ENCODED_POLYLINE) + + url(ROUTES_GOOGLE_APIS) + } + + install(HttpCallValidator) { + handleResponseExceptionWithRequest { exception, _ -> + if (exception is ClientRequestException) { + throw exception.response.body() + } + } + } + + expectSuccess = true + } + + override suspend fun invoke( + request: ComputeRoutesRequest, + ): ComputeRoutesResponse = httpClient + .post("/directions/v2:computeRoutes") { setBody(request) } + .body() +} + +@Serializable +public data class ComputeRoutesRequest( + val origin: Origin, + val destination: Destination, + val travelMode: TravelMode, + val departureTime: String, + val languageCode: String, + val units: Units, +) { + + public constructor( + origin: LatLng, + destination: LatLng, + departureTime: String = "${Clock.System.now()}", + ) : this( + origin = Origin(Location(origin)), + destination = Destination(Location(destination)), + travelMode = TravelMode.WALK, + departureTime = departureTime, + languageCode = "en-GB", + units = Units.METRIC, + ) + + @Serializable + public data class Origin( + val location: Location, + ) + + @Serializable + public data class Destination( + val location: Location, + ) + + @Serializable + public data class Location( + val latLng: LatLng, + ) + + @Serializable + public data class LatLng( + val latitude: Double, + val longitude: Double, + ) + + @Serializable + public enum class TravelMode { + WALK, + } + + @Serializable + public enum class Units { + METRIC, + } +} + +@Serializable +public data class ComputeRoutesResponse( + val routes: List, +) { + + @Serializable + public data class Route( + val distanceMeters: Int, + val duration: String, + val polyline: Polyline, + ) + + @Serializable + public data class Polyline( + val encodedPolyline: String, + ) +} + +@Serializable +public data class ComputeRoutesError( + val error: Error, +) : Throwable() { + + override val message: String + get() = error.message + + @Serializable + public data class Error( + val code: Int, + val message: String, + val status: String, + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8fef20279..3a35f78d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -71,6 +71,7 @@ include( ":identity-manager", ":kotlin-gb", ":map-routes", + ":maps-routing", ":micro-yaml", ":notion-console", ":nsd-manager",