Skip to content

Commit e2f99ba

Browse files
authored
feat: ✨ handle error state handy (#23)
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
1 parent e8d9ad8 commit e2f99ba

File tree

17 files changed

+198
-40
lines changed

17 files changed

+198
-40
lines changed
Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,55 @@
11
package es.ffgiraldez.comicsearch.comics.data
22

3+
import arrow.core.Either
34
import arrow.core.None
5+
import arrow.core.Option
46
import arrow.core.Some
7+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
8+
import es.ffgiraldez.comicsearch.comics.domain.ComicError.EmptyResultsError
9+
import es.ffgiraldez.comicsearch.comics.domain.ComicError.NetworkError
10+
import es.ffgiraldez.comicsearch.comics.domain.Query
11+
import es.ffgiraldez.comicsearch.platform.left
12+
import es.ffgiraldez.comicsearch.platform.right
513
import io.reactivex.Flowable
614

715
abstract class ComicRepository<T>(
816
private val local: ComicLocalDataSource<T>,
917
private val remote: ComicRemoteDataSource<T>
1018
) {
11-
fun findByTerm(term: String): Flowable<List<T>> =
19+
fun findByTerm(term: String): Flowable<Either<ComicError, List<T>>> =
1220
local.findQueryByTerm(term)
13-
.flatMap {
14-
when (it) {
15-
is None -> remote.findByTerm(term)
16-
.flatMapPublisher { local.insert(term, it).toFlowable<List<T>>() }
17-
is Some -> local.findByQuery(it.t)
21+
.flatMap { findSuggestions(it, term) }
22+
23+
private fun findSuggestions(
24+
query: Option<Query>,
25+
term: String
26+
): Flowable<out Either<ComicError, List<T>>> = when (query) {
27+
is None -> searchSuggestions(term)
28+
is Some -> fetchSuggestions(query)
29+
}
30+
31+
private fun searchSuggestions(term: String): Flowable<Either<ComicError, List<T>>> =
32+
remote.findByTerm(term)
33+
.map { right<ComicError, List<T>>(it) }
34+
.onErrorReturn { left<ComicError, List<T>>(NetworkError) }
35+
.flatMapPublisher { saveSuggestions(it, term) }
36+
37+
private fun saveSuggestions(
38+
results: Either<ComicError, List<T>>,
39+
term: String
40+
): Flowable<Either<ComicError, List<T>>> =
41+
results.fold({ _ ->
42+
Flowable.just(results)
43+
}, {
44+
local.insert(term, it).toFlowable<Either<ComicError, List<T>>>()
45+
})
46+
47+
private fun fetchSuggestions(it: Some<Query>): Flowable<Either<EmptyResultsError, List<T>>> =
48+
local.findByQuery(it.t)
49+
.map {
50+
when (it.isEmpty()) {
51+
true -> Either.left(EmptyResultsError)
52+
false -> Either.right(it)
1853
}
1954
}
2055
}

app/src/main/java/es/ffgiraldez/comicsearch/comics/di/comicModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import retrofit2.Retrofit
1111
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
1212
import retrofit2.converter.gson.GsonConverterFactory
1313

14-
const val ACTIVITY_PARAM: String = "activity"
1514
const val CONTEXT_PARAM: String = "context"
1615

1716
val comicModule = applicationContext {

app/src/main/java/es/ffgiraldez/comicsearch/comics/domain/Entities.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ data class Volume(
99
data class Query(
1010
val identifier: Long,
1111
val searchTerm: String
12-
)
12+
)
13+
14+
sealed class ComicError {
15+
object NetworkError : ComicError()
16+
object EmptyResultsError : ComicError()
17+
}

app/src/main/java/es/ffgiraldez/comicsearch/navigation/di/androidModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import es.ffgiraldez.comicsearch.navigation.Navigator
44
import org.koin.dsl.module.applicationContext
55

66
const val ACTIVITY_PARAM: String = "activity"
7-
const val CONTEXT_PARAM: String = "context"
87

98
val navigationModule = applicationContext {
109
factory { params -> Navigator(params[ACTIVITY_PARAM]) }
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package es.ffgiraldez.comicsearch.platform
2+
3+
import arrow.core.Either
4+
import arrow.core.left
5+
import arrow.core.right
6+
7+
fun <A, B, C> safe(first: A?, second: B?, block: (A, B) -> C): C? {
8+
return if (first != null && second != null) {
9+
block(first, second)
10+
} else {
11+
null
12+
}
13+
}
14+
15+
fun <A, B> left(a: A): Either<A, B> = a.left()
16+
fun <A, B> right(b: B): Either<A, B> = b.right()

app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewModel.kt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package es.ffgiraldez.comicsearch.query.base.presentation
22

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

1317
val query: MutableLiveData<String> = MutableLiveData()
18+
val error: MutableLiveData<Option<ComicError>> = MutableLiveData()
1419
val loading: MutableLiveData<Boolean> = MutableLiveData()
1520
val results: MutableLiveData<List<T>> = MutableLiveData()
1621

@@ -19,17 +24,17 @@ open class QueryViewModel<T>(
1924
.compose { transformer(it) }
2025
.subscribe {
2126
when (it) {
22-
QueryViewState.Idle -> applyState(false, emptyList())
23-
is QueryViewState.Loading -> applyState(true, emptyList())
24-
is QueryViewState.Result -> applyState(false, it.results)
27+
is QueryViewState.Loading -> applyState(isLoading = true)
28+
is QueryViewState.Idle -> applyState(isLoading = false)
29+
is QueryViewState.Error -> applyState(isLoading = false, error = it.error.some())
30+
is QueryViewState.Result -> applyState(isLoading = false, results = it.results)
2531
}
2632
}
27-
28-
2933
}
3034

31-
private fun applyState(isLoading: Boolean, results: List<T>) {
35+
private fun applyState(isLoading: Boolean, results: List<T> = emptyList(), error: Option<ComicError> = none()) {
3236
this.loading.postValue(isLoading)
3337
this.results.postValue(results)
38+
this.error.postValue(error)
3439
}
3540
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package es.ffgiraldez.comicsearch.query.base.presentation
22

3+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
4+
35
sealed class QueryViewState<out T> {
46

57
companion object {
68
fun <T> result(volumeList: List<T>): QueryViewState<T> = Result(volumeList)
79
fun <T> idle(): QueryViewState<T> = Idle
810
fun <T> loading(): QueryViewState<T> = Loading
11+
fun <T> error(error: ComicError): QueryViewState<T> = Error(error)
912
}
1013

1114
object Idle : QueryViewState<Nothing>()
1215
object Loading : QueryViewState<Nothing>()
1316
data class Result<out T>(val results: List<T>) : QueryViewState<T>()
17+
data class Error(val error: ComicError) : QueryViewState<Nothing>()
1418

1519
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package es.ffgiraldez.comicsearch.query.base.ui
2+
3+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
4+
5+
fun ComicError.toHumanResponse(): String = when (this) {
6+
ComicError.NetworkError -> "no internet connection"
7+
ComicError.EmptyResultsError -> "search without suggestion"
8+
}

app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QueryActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import android.databinding.DataBindingUtil
44
import android.os.Bundle
55
import android.support.v7.app.AppCompatActivity
66
import es.ffgiraldez.comicsearch.R
7+
import es.ffgiraldez.comicsearch.comics.di.CONTEXT_PARAM
78
import es.ffgiraldez.comicsearch.databinding.QueryActivityBinding
8-
import es.ffgiraldez.comicsearch.navigation.di.ACTIVITY_PARAM
9-
import es.ffgiraldez.comicsearch.navigation.di.CONTEXT_PARAM
109
import es.ffgiraldez.comicsearch.navigation.Navigator
10+
import es.ffgiraldez.comicsearch.navigation.di.ACTIVITY_PARAM
1111
import es.ffgiraldez.comicsearch.query.search.presentation.SearchViewModel
1212
import es.ffgiraldez.comicsearch.query.sugestion.presentation.SuggestionViewModel
1313
import org.koin.android.architecture.ext.viewModel

app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ package es.ffgiraldez.comicsearch.query.base.ui
33
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
44
import kotlinx.android.parcel.Parcelize
55

6-
@Parcelize
7-
data class QuerySearchSuggestion(
8-
private val volume: String
6+
sealed class QuerySearchSuggestion(
7+
private val suggestion: String
98
) : SearchSuggestion {
10-
override fun getBody(): String = volume
9+
override fun getBody(): String = suggestion
10+
11+
@Parcelize
12+
data class ResultSuggestion(val volume: String) : QuerySearchSuggestion(volume)
13+
14+
@Parcelize
15+
data class ErrorSuggestion(val volume: String) : QuerySearchSuggestion(volume)
16+
1117
}

app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,19 @@ class SearchViewModel private constructor(
1212
) : QueryViewModel<Volume>(queryToResult) {
1313
companion object {
1414
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
15-
it.switchMap {
15+
it.switchMap { handleQuery(repo, it) }
16+
.startWith(QueryViewState.idle())
17+
}
18+
19+
private fun handleQuery(repo: SearchRepository, it: String): Flowable<QueryViewState<Volume>> =
1620
repo.findByTerm(it)
17-
.map { QueryViewState.result(it) }
21+
.map {
22+
it.fold({
23+
QueryViewState.error<Volume>(it)
24+
}, {
25+
QueryViewState.result(it)
26+
})
27+
}
1828
.startWith(QueryViewState.loading())
19-
}.startWith(QueryViewState.idle())
20-
}
2129
}
2230
}

app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ package es.ffgiraldez.comicsearch.query.search.ui
22

33
import android.databinding.BindingAdapter
44
import android.support.v7.widget.RecyclerView
5+
import android.view.View
6+
import android.widget.FrameLayout
7+
import android.widget.TextView
8+
import arrow.core.Option
59
import com.arlib.floatingsearchview.FloatingSearchView
610
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
11+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
712
import es.ffgiraldez.comicsearch.comics.domain.Volume
813
import es.ffgiraldez.comicsearch.query.base.ui.OnVolumeSelectedListener
14+
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
915
import es.ffgiraldez.comicsearch.query.base.ui.QueryVolumeAdapter
16+
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
1017
import io.reactivex.functions.Consumer
1118

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

19-
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
26+
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion) {
2027
clickConsumer?.apply {
21-
clickConsumer.accept(searchSuggestion)
22-
search.setSearchFocused(false)
28+
when (searchSuggestion) {
29+
is ResultSuggestion -> {
30+
clickConsumer.accept(searchSuggestion)
31+
search.setSearchFocused(false)
32+
}
33+
}
2334
}
2435
}
2536
})
@@ -38,6 +49,20 @@ fun bindData(recycler: RecyclerView, queryAdapter: QueryVolumeAdapter, data: Lis
3849
data?.let { queryAdapter.submitList(data) }
3950
}
4051

52+
@BindingAdapter("error")
53+
fun bindDataError(recycler: RecyclerView, errorData: Option<ComicError>?) = errorData?.let { error ->
54+
error.fold({ View.VISIBLE }, { View.GONE }).let { recycler.visibility = it }
55+
}
56+
57+
@BindingAdapter("error")
58+
fun bindErrorVisibility(errorContainer: FrameLayout, errorData: Option<ComicError>?) = errorData?.let { error ->
59+
error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it }
60+
}
61+
62+
@BindingAdapter("error")
63+
fun bindErrorText(errorText: TextView, errorData: Option<ComicError>?) = errorData?.let { error ->
64+
error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
65+
}
4166

4267
interface ClickConsumer : Consumer<SearchSuggestion>
4368
interface SearchConsumer : Consumer<String>

app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/data/SuggestionRepository.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ import es.ffgiraldez.comicsearch.comics.data.ComicRepository
55
class SuggestionRepository(
66
local: SuggestionLocalDataSource,
77
remote: SuggestionRemoteDataSource
8-
) : ComicRepository<String>(local, remote)
8+
) : ComicRepository<String>(local, remote)

app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,31 @@ class SuggestionViewModel private constructor(
1313
companion object {
1414
operator fun invoke(repo: SuggestionRepository): SuggestionViewModel = SuggestionViewModel {
1515
it.debounce(400, TimeUnit.MILLISECONDS)
16-
.switchMap { query ->
17-
repo.findByTerm(query)
18-
.map { suggestions -> QueryViewState.result(suggestions) }
19-
.startWith(QueryViewState.loading())
20-
}.startWith(QueryViewState.idle())
16+
.switchMap { query -> handleQuery(query, repo) }
17+
.startWith(QueryViewState.idle())
2118
}
19+
20+
private fun handleQuery(
21+
query: String,
22+
repo: SuggestionRepository
23+
): Flowable<QueryViewState<String>> =
24+
if (query.isEmpty()) {
25+
Flowable.just(QueryViewState.idle())
26+
} else {
27+
searchSuggestions(repo, query)
28+
}
29+
30+
private fun searchSuggestions(
31+
repo: SuggestionRepository,
32+
query: String
33+
): Flowable<QueryViewState<String>> =
34+
repo.findByTerm(query)
35+
.map { suggestions ->
36+
suggestions.fold({
37+
QueryViewState.error<String>(it)
38+
}, {
39+
QueryViewState.result(it)
40+
})
41+
}.startWith(QueryViewState.loading())
2242
}
2343
}
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
package es.ffgiraldez.comicsearch.query.sugestion.ui
22

33
import android.databinding.BindingAdapter
4+
import arrow.core.Option
45
import com.arlib.floatingsearchview.FloatingSearchView
5-
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion
6+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
7+
import es.ffgiraldez.comicsearch.platform.safe
8+
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ErrorSuggestion
9+
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
10+
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
611

712

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

12-
@BindingAdapter("suggestions")
13-
fun bindSuggestions(search: FloatingSearchView, liveData: List<String>?) = liveData?.let {
14-
it.map { QuerySearchSuggestion(it) }.let { search.swapSuggestions(it) }
17+
@BindingAdapter("suggestions", "error")
18+
fun bindSuggestions(
19+
search: FloatingSearchView,
20+
resultData: List<String>?,
21+
errorData: Option<ComicError>?
22+
) = safe(errorData, resultData) { error, results ->
23+
error.fold({
24+
results.map { ResultSuggestion(it) }
25+
}, {
26+
listOf(ErrorSuggestion(it.toHumanResponse()))
27+
}).let { search.swapSuggestions(it) }
1528
}
1629

1730
@BindingAdapter("show_progress")
@@ -20,4 +33,5 @@ fun bindLoading(search: FloatingSearchView, liveData: Boolean?) = liveData?.let
2033
true -> search.showProgress()
2134
false -> search.hideProgress()
2235
}
23-
}
36+
}
37+

0 commit comments

Comments
 (0)