Skip to content

Commit

Permalink
Merge pull request #9 from trafi/location-search
Browse files Browse the repository at this point in the history
Android location search module
  • Loading branch information
justasm authored Oct 30, 2020
2 parents dbf2934 + b625697 commit 8441a7f
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 43 deletions.
43 changes: 39 additions & 4 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
114 changes: 75 additions & 39 deletions android/app/src/main/java/com/trafi/example/RoutesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,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
Expand All @@ -58,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
Expand Down Expand Up @@ -124,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,
Expand Down Expand Up @@ -174,7 +177,7 @@ private fun RouteSearchBody(
@Composable
private fun LocationSearchBody(
state: LocationSearchResultState,
onClick: (Location) -> Unit,
onClick: (AutoCompleteLocation) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
Expand Down Expand Up @@ -220,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,
Expand All @@ -230,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),
Expand Down Expand Up @@ -416,33 +436,49 @@ class RoutesViewModel : ViewModel() {
sealed class LocationSearchResultState {
object NoResults : LocationSearchResultState()
object Loading : LocationSearchResultState()
data class Loaded(val result: List<Location>) : LocationSearchResultState()
data class Loaded(val result: List<AutoCompleteLocation>) : 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
Expand Down
1 change: 1 addition & 0 deletions android/locations/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
30 changes: 30 additions & 0 deletions android/locations/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
Empty file.
2 changes: 2 additions & 0 deletions android/locations/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.trafi.locations" />
Original file line number Diff line number Diff line change
@@ -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<List<AutoCompleteLocation>> = 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<Location> = 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<String?> = 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)
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 8441a7f

Please sign in to comment.