Skip to content

Commit

Permalink
feat: ✨ handle error state handy (#23)
Browse files Browse the repository at this point in the history
Create a new property on QueryViewModel error to describe an error on UI
Create a new QueryViewState error to model the state
To not duplicate code on data sources, errors are handled on ComicRepository
Model error using Either<ComicError, List<T>>
Empty results on ComicLocalDataSource produce EmptyResultsError
Any network error produce NetworkError
Suggestions will display error as a single suggestion
A search will display error as text on the screen, hiding result list
Error suggestions do not propagate search results

closes #16
  • Loading branch information
ffgiraldez authored May 11, 2018
1 parent e8d9ad8 commit e2f99ba
Show file tree
Hide file tree
Showing 17 changed files with 198 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -1,20 +1,55 @@
package es.ffgiraldez.comicsearch.comics.data

import arrow.core.Either
import arrow.core.None
import arrow.core.Option
import arrow.core.Some
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.comics.domain.ComicError.EmptyResultsError
import es.ffgiraldez.comicsearch.comics.domain.ComicError.NetworkError
import es.ffgiraldez.comicsearch.comics.domain.Query
import es.ffgiraldez.comicsearch.platform.left
import es.ffgiraldez.comicsearch.platform.right
import io.reactivex.Flowable

abstract class ComicRepository<T>(
private val local: ComicLocalDataSource<T>,
private val remote: ComicRemoteDataSource<T>
) {
fun findByTerm(term: String): Flowable<List<T>> =
fun findByTerm(term: String): Flowable<Either<ComicError, List<T>>> =
local.findQueryByTerm(term)
.flatMap {
when (it) {
is None -> remote.findByTerm(term)
.flatMapPublisher { local.insert(term, it).toFlowable<List<T>>() }
is Some -> local.findByQuery(it.t)
.flatMap { findSuggestions(it, term) }

private fun findSuggestions(
query: Option<Query>,
term: String
): Flowable<out Either<ComicError, List<T>>> = when (query) {
is None -> searchSuggestions(term)
is Some -> fetchSuggestions(query)
}

private fun searchSuggestions(term: String): Flowable<Either<ComicError, List<T>>> =
remote.findByTerm(term)
.map { right<ComicError, List<T>>(it) }
.onErrorReturn { left<ComicError, List<T>>(NetworkError) }
.flatMapPublisher { saveSuggestions(it, term) }

private fun saveSuggestions(
results: Either<ComicError, List<T>>,
term: String
): Flowable<Either<ComicError, List<T>>> =
results.fold({ _ ->
Flowable.just(results)
}, {
local.insert(term, it).toFlowable<Either<ComicError, List<T>>>()
})

private fun fetchSuggestions(it: Some<Query>): Flowable<Either<EmptyResultsError, List<T>>> =
local.findByQuery(it.t)
.map {
when (it.isEmpty()) {
true -> Either.left(EmptyResultsError)
false -> Either.right(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory

const val ACTIVITY_PARAM: String = "activity"
const val CONTEXT_PARAM: String = "context"

val comicModule = applicationContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ data class Volume(
data class Query(
val identifier: Long,
val searchTerm: String
)
)

sealed class ComicError {
object NetworkError : ComicError()
object EmptyResultsError : ComicError()
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import es.ffgiraldez.comicsearch.navigation.Navigator
import org.koin.dsl.module.applicationContext

const val ACTIVITY_PARAM: String = "activity"
const val CONTEXT_PARAM: String = "context"

val navigationModule = applicationContext {
factory { params -> Navigator(params[ACTIVITY_PARAM]) }
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/es/ffgiraldez/comicsearch/platform/Utilities.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package es.ffgiraldez.comicsearch.platform

import arrow.core.Either
import arrow.core.left
import arrow.core.right

fun <A, B, C> safe(first: A?, second: B?, block: (A, B) -> C): C? {
return if (first != null && second != null) {
block(first, second)
} else {
null
}
}

fun <A, B> left(a: A): Either<A, B> = a.left()
fun <A, B> right(b: B): Either<A, B> = b.right()
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package es.ffgiraldez.comicsearch.query.base.presentation

import android.arch.lifecycle.MutableLiveData
import android.arch.lifecycle.ViewModel
import arrow.core.Option
import arrow.core.none
import arrow.core.some
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.platform.toFlowable
import io.reactivex.Flowable
import org.reactivestreams.Publisher
Expand All @@ -11,6 +15,7 @@ open class QueryViewModel<T>(
) : ViewModel() {

val query: MutableLiveData<String> = MutableLiveData()
val error: MutableLiveData<Option<ComicError>> = MutableLiveData()
val loading: MutableLiveData<Boolean> = MutableLiveData()
val results: MutableLiveData<List<T>> = MutableLiveData()

Expand All @@ -19,17 +24,17 @@ open class QueryViewModel<T>(
.compose { transformer(it) }
.subscribe {
when (it) {
QueryViewState.Idle -> applyState(false, emptyList())
is QueryViewState.Loading -> applyState(true, emptyList())
is QueryViewState.Result -> applyState(false, it.results)
is QueryViewState.Loading -> applyState(isLoading = true)
is QueryViewState.Idle -> applyState(isLoading = false)
is QueryViewState.Error -> applyState(isLoading = false, error = it.error.some())
is QueryViewState.Result -> applyState(isLoading = false, results = it.results)
}
}


}

private fun applyState(isLoading: Boolean, results: List<T>) {
private fun applyState(isLoading: Boolean, results: List<T> = emptyList(), error: Option<ComicError> = none()) {
this.loading.postValue(isLoading)
this.results.postValue(results)
this.error.postValue(error)
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package es.ffgiraldez.comicsearch.query.base.presentation

import es.ffgiraldez.comicsearch.comics.domain.ComicError

sealed class QueryViewState<out T> {

companion object {
fun <T> result(volumeList: List<T>): QueryViewState<T> = Result(volumeList)
fun <T> idle(): QueryViewState<T> = Idle
fun <T> loading(): QueryViewState<T> = Loading
fun <T> error(error: ComicError): QueryViewState<T> = Error(error)
}

object Idle : QueryViewState<Nothing>()
object Loading : QueryViewState<Nothing>()
data class Result<out T>(val results: List<T>) : QueryViewState<T>()
data class Error(val error: ComicError) : QueryViewState<Nothing>()

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package es.ffgiraldez.comicsearch.query.base.ui

import es.ffgiraldez.comicsearch.comics.domain.ComicError

fun ComicError.toHumanResponse(): String = when (this) {
ComicError.NetworkError -> "no internet connection"
ComicError.EmptyResultsError -> "search without suggestion"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import android.databinding.DataBindingUtil
import android.os.Bundle
import android.support.v7.app.AppCompatActivity
import es.ffgiraldez.comicsearch.R
import es.ffgiraldez.comicsearch.comics.di.CONTEXT_PARAM
import es.ffgiraldez.comicsearch.databinding.QueryActivityBinding
import es.ffgiraldez.comicsearch.navigation.di.ACTIVITY_PARAM
import es.ffgiraldez.comicsearch.navigation.di.CONTEXT_PARAM
import es.ffgiraldez.comicsearch.navigation.Navigator
import es.ffgiraldez.comicsearch.navigation.di.ACTIVITY_PARAM
import es.ffgiraldez.comicsearch.query.search.presentation.SearchViewModel
import es.ffgiraldez.comicsearch.query.sugestion.presentation.SuggestionViewModel
import org.koin.android.architecture.ext.viewModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ package es.ffgiraldez.comicsearch.query.base.ui
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
import kotlinx.android.parcel.Parcelize

@Parcelize
data class QuerySearchSuggestion(
private val volume: String
sealed class QuerySearchSuggestion(
private val suggestion: String
) : SearchSuggestion {
override fun getBody(): String = volume
override fun getBody(): String = suggestion

@Parcelize
data class ResultSuggestion(val volume: String) : QuerySearchSuggestion(volume)

@Parcelize
data class ErrorSuggestion(val volume: String) : QuerySearchSuggestion(volume)

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,19 @@ class SearchViewModel private constructor(
) : QueryViewModel<Volume>(queryToResult) {
companion object {
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
it.switchMap {
it.switchMap { handleQuery(repo, it) }
.startWith(QueryViewState.idle())
}

private fun handleQuery(repo: SearchRepository, it: String): Flowable<QueryViewState<Volume>> =
repo.findByTerm(it)
.map { QueryViewState.result(it) }
.map {
it.fold({
QueryViewState.error<Volume>(it)
}, {
QueryViewState.result(it)
})
}
.startWith(QueryViewState.loading())
}.startWith(QueryViewState.idle())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ package es.ffgiraldez.comicsearch.query.search.ui

import android.databinding.BindingAdapter
import android.support.v7.widget.RecyclerView
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import arrow.core.Option
import com.arlib.floatingsearchview.FloatingSearchView
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.comics.domain.Volume
import es.ffgiraldez.comicsearch.query.base.ui.OnVolumeSelectedListener
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
import es.ffgiraldez.comicsearch.query.base.ui.QueryVolumeAdapter
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
import io.reactivex.functions.Consumer

@BindingAdapter("on_suggestion_click", "on_search", requireAll = false)
Expand All @@ -16,10 +23,14 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
searchConsumer?.apply { searchConsumer.accept(currentQuery) }
}

override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion) {
clickConsumer?.apply {
clickConsumer.accept(searchSuggestion)
search.setSearchFocused(false)
when (searchSuggestion) {
is ResultSuggestion -> {
clickConsumer.accept(searchSuggestion)
search.setSearchFocused(false)
}
}
}
}
})
Expand All @@ -38,6 +49,20 @@ fun bindData(recycler: RecyclerView, queryAdapter: QueryVolumeAdapter, data: Lis
data?.let { queryAdapter.submitList(data) }
}

@BindingAdapter("error")
fun bindDataError(recycler: RecyclerView, errorData: Option<ComicError>?) = errorData?.let { error ->
error.fold({ View.VISIBLE }, { View.GONE }).let { recycler.visibility = it }
}

@BindingAdapter("error")
fun bindErrorVisibility(errorContainer: FrameLayout, errorData: Option<ComicError>?) = errorData?.let { error ->
error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it }
}

@BindingAdapter("error")
fun bindErrorText(errorText: TextView, errorData: Option<ComicError>?) = errorData?.let { error ->
error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
}

interface ClickConsumer : Consumer<SearchSuggestion>
interface SearchConsumer : Consumer<String>
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ import es.ffgiraldez.comicsearch.comics.data.ComicRepository
class SuggestionRepository(
local: SuggestionLocalDataSource,
remote: SuggestionRemoteDataSource
) : ComicRepository<String>(local, remote)
) : ComicRepository<String>(local, remote)
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,31 @@ class SuggestionViewModel private constructor(
companion object {
operator fun invoke(repo: SuggestionRepository): SuggestionViewModel = SuggestionViewModel {
it.debounce(400, TimeUnit.MILLISECONDS)
.switchMap { query ->
repo.findByTerm(query)
.map { suggestions -> QueryViewState.result(suggestions) }
.startWith(QueryViewState.loading())
}.startWith(QueryViewState.idle())
.switchMap { query -> handleQuery(query, repo) }
.startWith(QueryViewState.idle())
}

private fun handleQuery(
query: String,
repo: SuggestionRepository
): Flowable<QueryViewState<String>> =
if (query.isEmpty()) {
Flowable.just(QueryViewState.idle())
} else {
searchSuggestions(repo, query)
}

private fun searchSuggestions(
repo: SuggestionRepository,
query: String
): Flowable<QueryViewState<String>> =
repo.findByTerm(query)
.map { suggestions ->
suggestions.fold({
QueryViewState.error<String>(it)
}, {
QueryViewState.result(it)
})
}.startWith(QueryViewState.loading())
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
package es.ffgiraldez.comicsearch.query.sugestion.ui

import android.databinding.BindingAdapter
import arrow.core.Option
import com.arlib.floatingsearchview.FloatingSearchView
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.platform.safe
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ErrorSuggestion
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse


@BindingAdapter("on_change")
fun bindQueryChangeListener(search: FloatingSearchView, listener: FloatingSearchView.OnQueryChangeListener) =
search.setOnQueryChangeListener(listener)

@BindingAdapter("suggestions")
fun bindSuggestions(search: FloatingSearchView, liveData: List<String>?) = liveData?.let {
it.map { QuerySearchSuggestion(it) }.let { search.swapSuggestions(it) }
@BindingAdapter("suggestions", "error")
fun bindSuggestions(
search: FloatingSearchView,
resultData: List<String>?,
errorData: Option<ComicError>?
) = safe(errorData, resultData) { error, results ->
error.fold({
results.map { ResultSuggestion(it) }
}, {
listOf(ErrorSuggestion(it.toHumanResponse()))
}).let { search.swapSuggestions(it) }
}

@BindingAdapter("show_progress")
Expand All @@ -20,4 +33,5 @@ fun bindLoading(search: FloatingSearchView, liveData: Boolean?) = liveData?.let
true -> search.showProgress()
false -> search.hideProgress()
}
}
}

Loading

0 comments on commit e2f99ba

Please sign in to comment.