From 9d505ca4f39034112b299a9d4fe1da1a55c7e3ca Mon Sep 17 00:00:00 2001 From: Justas Medeisis Date: Fri, 30 Oct 2020 00:40:12 +0200 Subject: [PATCH 1/4] Add LocationsApi via a new locations module. --- android/locations/.gitignore | 1 + android/locations/build.gradle | 30 ++++++++ android/locations/consumer-rules.pro | 0 .../locations/src/main/AndroidManifest.xml | 2 + .../java/com/trafi/locations/LocationsApi.kt | 76 +++++++++++++++++++ .../com/trafi/locations/LocationsService.kt | 31 ++++++++ android/settings.gradle | 1 + 7 files changed, 141 insertions(+) create mode 100644 android/locations/.gitignore create mode 100644 android/locations/build.gradle create mode 100644 android/locations/consumer-rules.pro create mode 100644 android/locations/src/main/AndroidManifest.xml create mode 100644 android/locations/src/main/java/com/trafi/locations/LocationsApi.kt create mode 100644 android/locations/src/main/java/com/trafi/locations/LocationsService.kt diff --git a/android/locations/.gitignore b/android/locations/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/android/locations/.gitignore @@ -0,0 +1 @@ +/build diff --git a/android/locations/build.gradle b/android/locations/build.gradle new file mode 100644 index 000000000..1faca0f5e --- /dev/null +++ b/android/locations/build.gradle @@ -0,0 +1,30 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' +} + +android { + compileSdkVersion 30 + + defaultConfig { + consumerProguardFiles "consumer-rules.pro" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation project(":core") + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'com.squareup.okhttp3:okhttp:4.9.0' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' + implementation 'com.squareup.moshi:moshi:1.10.0' + implementation 'com.squareup.moshi:moshi-kotlin:1.10.0' +} diff --git a/android/locations/consumer-rules.pro b/android/locations/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/locations/src/main/AndroidManifest.xml b/android/locations/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a8b5f38a4 --- /dev/null +++ b/android/locations/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/locations/src/main/java/com/trafi/locations/LocationsApi.kt b/android/locations/src/main/java/com/trafi/locations/LocationsApi.kt new file mode 100644 index 000000000..f27d9a64d --- /dev/null +++ b/android/locations/src/main/java/com/trafi/locations/LocationsApi.kt @@ -0,0 +1,76 @@ +package com.trafi.locations + +import android.util.Log +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.trafi.core.ApiResult +import com.trafi.core.android.model.AutoCompleteLocation +import com.trafi.core.android.model.LatLng +import com.trafi.core.android.model.Location +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +class LocationsApi(baseUrl: String, apiKey: String, private val regionId: String) { + + private val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + private val okhttp = OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("accept-language", "en") // required for v1/autocomplete + .addHeader("x-api-key", apiKey) + .build() + chain.proceed(request) + } + .build() + private val service: LocationsService = Retrofit.Builder() + .client(okhttp) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .baseUrl(baseUrl) + .build() + .create(LocationsService::class.java) + + suspend fun search( + query: String, + coordinate: LatLng? = null + ): ApiResult> = try { + val result = service.search( + query = query, + regionId = regionId, + lat = coordinate?.lat, + lng = coordinate?.lng, + ) + ApiResult.Success(result.locations) + } catch (e: Throwable) { + e.logError() + ApiResult.Failure(e) + } + + suspend fun resolveLocation(location: AutoCompleteLocation): ApiResult = try { + location.toLocation()?.let { ApiResult.Success(it) } + ?: service.resolveCoordinate(location.id).toLocation()?.let { ApiResult.Success(it) } + ?: ApiResult.Failure(IllegalArgumentException("Failed to resolve coordinate for location id ${location.id}")) + } catch (e: Throwable) { + e.logError() + ApiResult.Failure(e) + } + + suspend fun resolveAddress(coordinate: LatLng): ApiResult = try { + val result = service.reverseGeocode(coordinate.lat, coordinate.lng) + ApiResult.Success(result.address) + } catch (e: Throwable) { + e.logError() + ApiResult.Failure(e) + } +} + +private fun Throwable.logError() = (this as? HttpException)?.let { + Log.e("LocationsApi", response()?.errorBody()?.string().orEmpty()) +} + +private fun AutoCompleteLocation.toLocation(): Location? = coordinate?.let { coordinate -> + Location(coordinate = coordinate, name = name, address = address) +} diff --git a/android/locations/src/main/java/com/trafi/locations/LocationsService.kt b/android/locations/src/main/java/com/trafi/locations/LocationsService.kt new file mode 100644 index 000000000..44fac48d1 --- /dev/null +++ b/android/locations/src/main/java/com/trafi/locations/LocationsService.kt @@ -0,0 +1,31 @@ +package com.trafi.locations + +import com.trafi.core.android.model.AutoCompleteLocation +import com.trafi.core.android.model.AutoCompleteLocations +import com.trafi.core.android.model.ReverseGeocodeResponse +import com.trafi.core.android.model.RoutesResult +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +internal interface LocationsService { + @GET("v1/autocomplete") + suspend fun search( + @Query("q") query: String, + @Query("regionId") regionId: String, + @Query("subregionId") subregionId: String? = null, + @Query("lat") lat: Double? = null, + @Query("lng") lng: Double? = null, + ): AutoCompleteLocations + + @GET("v1/autocomplete/id/{locationId}") + suspend fun resolveCoordinate( + @Path("locationId") locationId: String, + ): AutoCompleteLocation + + @GET("v1/location/reversegeocode") + suspend fun reverseGeocode( + @Query("lat") lat: Double, + @Query("lng") lng: Double + ): ReverseGeocodeResponse +} diff --git a/android/settings.gradle b/android/settings.gradle index b59f31a80..8b9f93cf3 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -2,6 +2,7 @@ rootProject.name = "maas-components-android" include ':app' include ':core' include ':ui' +include ':locations' include ':routes' include ':routes:ui' From d183bfd6f2bd958fe365960947900a658c380a94 Mon Sep 17 00:00:00 2001 From: Justas Medeisis Date: Fri, 30 Oct 2020 00:40:37 +0200 Subject: [PATCH 2/4] Unused import. --- android/app/src/main/java/com/trafi/example/RoutesScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android/app/src/main/java/com/trafi/example/RoutesScreen.kt b/android/app/src/main/java/com/trafi/example/RoutesScreen.kt index 2acd9f7f8..e71fdec60 100644 --- a/android/app/src/main/java/com/trafi/example/RoutesScreen.kt +++ b/android/app/src/main/java/com/trafi/example/RoutesScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.material.Surface import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.runtime.Composable From 9a38a278fda3ae35b5f72ab3bb1f1709244d419d Mon Sep 17 00:00:00 2001 From: Justas Medeisis Date: Fri, 30 Oct 2020 00:41:49 +0200 Subject: [PATCH 3/4] Use LocationsApi for proper location search in the routes demo screen. --- android/app/build.gradle | 1 + .../java/com/trafi/example/RoutesScreen.kt | 113 ++++++++++++------ 2 files changed, 76 insertions(+), 38 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f703b0ea..4ec970357 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,6 +51,7 @@ androidExtensions { dependencies { implementation project(":core") implementation project(":ui") + implementation project(":locations") implementation project(":routes") implementation project(":routes:ui") implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" diff --git a/android/app/src/main/java/com/trafi/example/RoutesScreen.kt b/android/app/src/main/java/com/trafi/example/RoutesScreen.kt index e71fdec60..bc8517546 100644 --- a/android/app/src/main/java/com/trafi/example/RoutesScreen.kt +++ b/android/app/src/main/java/com/trafi/example/RoutesScreen.kt @@ -45,10 +45,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.ui.tooling.preview.Preview import com.trafi.core.ApiResult +import com.trafi.core.android.model.AutoCompleteLocation import com.trafi.core.android.model.LatLng import com.trafi.core.android.model.Location import com.trafi.core.android.model.RoutesResult import com.trafi.example.ui.DemoMaasTheme +import com.trafi.locations.LocationsApi import com.trafi.routes.RoutesApi import com.trafi.routes.ui.R import com.trafi.routes.ui.RoutesResult @@ -57,7 +59,6 @@ import com.trafi.ui.theme.Grey500 import com.trafi.ui.theme.MaasTheme import com.trafi.ui.theme.Spacing import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -123,22 +124,25 @@ fun RoutesScreen(onBackClick: () -> Unit) { .padding(top = Spacing.md), onClick = { location -> softwareKeyboardController?.hideSoftwareKeyboard() - when { - searchingForStart -> { - viewModel.start = location - startText = location.displayText - viewModel.search() - searchingForStart = false - } - searchingForEnd -> { - viewModel.end = location - endText = location.displayText - viewModel.search() - searchingForEnd = false - } - } + locationViewModel.resolveLocation(location) } ) + locationViewModel.resolvedLocation?.let { location -> + when { + searchingForStart -> { + viewModel.start = location + startText = location.displayText + viewModel.search() + searchingForStart = false + } + searchingForEnd -> { + viewModel.end = location + endText = location.displayText + viewModel.search() + searchingForEnd = false + } + } + } } else { RouteSearchBody( state = viewModel.routesResultState, @@ -173,7 +177,7 @@ private fun RouteSearchBody( @Composable private fun LocationSearchBody( state: LocationSearchResultState, - onClick: (Location) -> Unit, + onClick: (AutoCompleteLocation) -> Unit, modifier: Modifier = Modifier, ) { when (state) { @@ -219,7 +223,26 @@ private fun LocationResultPreview() { } @Composable -private fun LocationResult(location: Location, onClick: () -> Unit, modifier: Modifier = Modifier) { +private fun LocationResult( + location: Location, + onClick: () -> Unit, + modifier: Modifier = Modifier +) = LocationResult(location.name ?: location.coordinate.toString(), null, onClick, modifier) + +@Composable +private fun LocationResult( + location: AutoCompleteLocation, + onClick: () -> Unit, + modifier: Modifier = Modifier +) = LocationResult(location.name, location.address, onClick, modifier) + +@Composable +private fun LocationResult( + name: String, + address: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { Surface(modifier = modifier.clickable(onClick = onClick)) { Row( verticalAlignment = Alignment.CenterVertically, @@ -229,13 +252,11 @@ private fun LocationResult(location: Location, onClick: () -> Unit, modifier: Mo ) { Icon(Icons.Outlined.LocationOn, modifier = Modifier.size(24.dp)) Column(modifier = Modifier.padding(start = 12.dp)) { - location.name?.let { - Text( - it, - style = MaasTheme.typography.textL.copy(fontWeight = FontWeight.SemiBold), - ) - } - location.address?.let { + Text( + name, + style = MaasTheme.typography.textL.copy(fontWeight = FontWeight.SemiBold), + ) + address?.let { Text( it, style = MaasTheme.typography.textM.copy(color = Grey500), @@ -415,33 +436,49 @@ class RoutesViewModel : ViewModel() { sealed class LocationSearchResultState { object NoResults : LocationSearchResultState() object Loading : LocationSearchResultState() - data class Loaded(val result: List) : LocationSearchResultState() + data class Loaded(val result: List) : LocationSearchResultState() } class LocationSearchViewModel : ViewModel() { + private val locationsApi = LocationsApi( + baseUrl = BuildConfig.API_BASE_URL, + apiKey = BuildConfig.API_KEY, + regionId = "vilnius" + ) + private var job: Job? = null - var state: LocationSearchResultState - by mutableStateOf(LocationSearchResultState.NoResults) + var state: LocationSearchResultState by mutableStateOf(LocationSearchResultState.NoResults) + private set + var resolvedLocation: Location? by mutableStateOf(null) + private set fun search(query: String) { job?.cancel() state = LocationSearchResultState.Loading job = viewModelScope.launch { - delay(500) - val results = locations.filter { location -> - listOfNotNull( - location.name, - location.address - ).any { it.contains(query, ignoreCase = true) } - } - state = if (results.isNotEmpty()) { - LocationSearchResultState.Loaded(results) - } else { - LocationSearchResultState.NoResults + val result = locationsApi.search(query) + state = when (result) { + is ApiResult.Success -> { + val results = result.value + if (results.isNotEmpty()) { + LocationSearchResultState.Loaded(results) + } else { + LocationSearchResultState.NoResults + } + } + is ApiResult.Failure -> LocationSearchResultState.NoResults } } } + + fun resolveLocation(location: AutoCompleteLocation) { + job?.cancel() + state = LocationSearchResultState.Loading + job = viewModelScope.launch { + resolvedLocation = (locationsApi.resolveLocation(location) as? ApiResult.Success)?.value + } + } } @Stable From b625697a890d21489d29a6d5129dbfa187de166b Mon Sep 17 00:00:00 2001 From: Justas Medeisis Date: Fri, 30 Oct 2020 01:26:33 +0200 Subject: [PATCH 4/4] Add LocationsApi usage to README. --- android/README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/android/README.md b/android/README.md index 50f7642a6..7f7a6d3e6 100644 --- a/android/README.md +++ b/android/README.md @@ -7,8 +7,47 @@ Ready-built UI components to get you started quickly out of the box, built with ### Usage +Search for locations. + +```groovy +dependencies { + implementation 'com.trafi.maas:locations-android:0.1.0-dev01' +} +``` + +```kotlin +val locationsApi = LocationsApi(baseUrl = "$API_BASE_URL", apiKey = "$API_KEY", regionId = "$REGION_ID") + +lifecycleScope.launch { + val result = locationsApi.search(query) + when (result) { + is ApiResult.Success -> println("Found ${result.value.size} locations.") + is ApiResult.Failure -> throw result.exception + } +} +``` + +Geocode and reverse geocode locations. + +```kotlin +lifecycleScope.launch { + val start = locationsApi.resolveLocation(location) + + val coordinate = LatLng(54.685563, 25.287704) + val end = (locationsApi.resolveAddress(coordinate) as? ApiResult.Success)?.value?.let { address -> + Location(coordinate, address = address) + } +} +``` + Search for routes. +```groovy +dependencies { + implementation 'com.trafi.maas:routes-android:0.1.0-dev01' +} +``` + ```kotlin val routesApi = RoutesApi(baseUrl = "$API_BASE_URL", apiKey = "$API_KEY") @@ -31,9 +70,5 @@ setContent { Or try out the [included sample][sample]. -### Installation - -*Coming soon* - [sample]: https://github.com/trafi/maas-components-android/tree/master/app [compose]: https://developer.android.com/jetpack/compose