Skip to content

Commit 306f3ae

Browse files
authored
feat: 🏗️ simplify viewmodel state (#26)
- apply state on UI layer for search - apply state on UI layer for suggestions - little changes and package movements
1 parent a00ed21 commit 306f3ae

File tree

14 files changed

+140
-112
lines changed

14 files changed

+140
-112
lines changed

app/src/main/java/es/ffgiraldez/comicsearch/platform/LiveDataExt.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import io.reactivex.android.MainThreadDisposable
99
fun <T> LiveData<T>.toFlowable(): Flowable<T> =
1010
Flowable.create({ emitter ->
1111
val observer = Observer<T> {
12-
it?.let { emitter.onNext(it) }
12+
it?.let(emitter::onNext)
1313
}
1414
observeForever(observer)
1515

app/src/main/java/es/ffgiraldez/comicsearch/platform/Utilities.kt

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ package es.ffgiraldez.comicsearch.platform
33
import arrow.core.Either
44
import arrow.core.left
55
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-
}
6+
import io.reactivex.disposables.CompositeDisposable
7+
import io.reactivex.disposables.Disposable
148

159
fun <A, B> left(a: A): Either<A, B> = a.left()
16-
fun <A, B> right(b: B): Either<A, B> = b.right()
10+
fun <A, B> right(b: B): Either<A, B> = b.right()
11+
12+
operator fun CompositeDisposable.plus(disposable: Disposable): CompositeDisposable = apply {
13+
add(disposable)
14+
}

app/src/main/java/es/ffgiraldez/comicsearch/platform/ViewBinding.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package es.ffgiraldez.comicsearch.platform
22

3-
import androidx.databinding.BindingAdapter
43
import android.view.View
54

6-
@BindingAdapter("gone")
7-
fun bindImage(view: View, gone: Boolean) = with(view) {
5+
fun View.gone(gone: Boolean) = with(this) {
86
visibility = when (gone) {
97
true -> View.GONE
108
false -> View.VISIBLE
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package es.ffgiraldez.comicsearch.query.base.presentation
2+
3+
4+
import androidx.lifecycle.LiveData
5+
import androidx.lifecycle.MutableLiveData
6+
import androidx.lifecycle.ViewModel
7+
import es.ffgiraldez.comicsearch.platform.plus
8+
import es.ffgiraldez.comicsearch.platform.toFlowable
9+
import io.reactivex.Flowable
10+
import io.reactivex.disposables.CompositeDisposable
11+
import org.reactivestreams.Publisher
12+
13+
open class QueryStateViewModel<T>(
14+
transformer: (Flowable<String>) -> Publisher<QueryViewState<T>>
15+
) : ViewModel() {
16+
17+
private val _state: MutableLiveData<QueryViewState<T>> = MutableLiveData()
18+
val state: LiveData<QueryViewState<T>>
19+
get() = _state
20+
val query: MutableLiveData<String> = MutableLiveData()
21+
22+
private val disposable: CompositeDisposable = CompositeDisposable()
23+
24+
init {
25+
disposable + query.toFlowable()
26+
.compose { transformer(it) }
27+
.subscribe { _state.postValue(it) }
28+
}
29+
30+
override fun onCleared(): Unit = disposable.clear()
31+
}
32+

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

Lines changed: 0 additions & 41 deletions
This file was deleted.

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ sealed class QueryViewState<out T> {
1313

1414
object Idle : QueryViewState<Nothing>()
1515
object Loading : QueryViewState<Nothing>()
16-
data class Result<out T>(val results: List<T>) : QueryViewState<T>()
17-
data class Error(val error: ComicError) : QueryViewState<Nothing>()
16+
data class Result<out T>(val _results: List<T>) : QueryViewState<T>()
17+
data class Error(val _error: ComicError) : QueryViewState<Nothing>()
18+
19+
}
1820

19-
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ class QueryScreenDelegate(
1212
val adapter: QueryVolumeAdapter,
1313
private val navigator: Navigator
1414
) {
15-
fun onVolumeSelected(volume: Volume) =
15+
fun onVolumeSelected(volume: Volume): Unit =
1616
navigator.to(Screen.Detail(volume))
1717

18-
fun onQueryChange(new: String) =
18+
fun onQueryChange(new: String): Unit =
1919
with(suggestions) { query.value = new }
2020

21-
fun onSuggestionSelected(suggestion: String) =
21+
fun onSuggestionSelected(suggestion: String): Unit =
2222
with(search) { query.value = suggestion }
2323
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package es.ffgiraldez.comicsearch.query.base.ui
2+
3+
import arrow.core.Option
4+
import arrow.core.toOption
5+
import es.ffgiraldez.comicsearch.comics.domain.ComicError
6+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
7+
8+
val <T>QueryViewState<T>.error: Option<ComicError>
9+
get() = when (this) {
10+
is QueryViewState.Error -> _error.toOption()
11+
else -> Option.empty()
12+
}
13+
14+
val <T>QueryViewState<T>.results: List<T>
15+
get() = when (this) {
16+
is QueryViewState.Result -> _results
17+
else -> emptyList()
18+
}
19+
20+
val <T>QueryViewState<T>.loading: Boolean
21+
get() = when (this) {
22+
is QueryViewState.Loading -> true
23+
else -> false
24+
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import es.ffgiraldez.comicsearch.databinding.QueryItemBinding
1010

1111
class QueryVolumeAdapter : ListAdapter<Volume, QueryVolumeViewHolder>(asyncDiff) {
1212

13-
var onVolumeSelectedListener: OnVolumeSelectedListener? = null
13+
var onVolumeSelectedListener: OnVolumeSelectedListener = OnVolumeSelectedListener.empty
1414

1515
companion object {
1616
val asyncDiff: DiffUtil.ItemCallback<Volume> = object : DiffUtil.ItemCallback<Volume>() {
@@ -28,11 +28,17 @@ class QueryVolumeAdapter : ListAdapter<Volume, QueryVolumeViewHolder>(asyncDiff)
2828

2929
override fun onBindViewHolder(holder: QueryVolumeViewHolder, position: Int) {
3030
holder.bind(getItem(position))
31-
holder.itemView.setOnClickListener { onVolumeSelectedListener?.onVolumeSelected(getItem(position)) }
31+
holder.itemView.setOnClickListener { onVolumeSelectedListener.onVolumeSelected(getItem(position)) }
3232
}
3333
}
3434

3535
interface OnVolumeSelectedListener {
36+
companion object {
37+
val empty: OnVolumeSelectedListener = object : OnVolumeSelectedListener {
38+
override fun onVolumeSelected(volume: Volume) {}
39+
}
40+
}
41+
3642
fun onVolumeSelected(volume: Volume)
3743
}
3844

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
package es.ffgiraldez.comicsearch.query.search.presentation
22

33
import es.ffgiraldez.comicsearch.comics.domain.Volume
4-
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewModel
4+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
55
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
66
import es.ffgiraldez.comicsearch.query.search.data.SearchRepository
77
import io.reactivex.Flowable
88
import org.reactivestreams.Publisher
99

1010
class SearchViewModel private constructor(
1111
queryToResult: (Flowable<String>) -> Publisher<QueryViewState<Volume>>
12-
) : QueryViewModel<Volume>(queryToResult) {
12+
) : QueryStateViewModel<Volume>(queryToResult) {
1313
companion object {
14-
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
14+
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel { it ->
1515
it.switchMap { handleQuery(repo, it) }
1616
.startWith(QueryViewState.idle())
1717
}
Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
package es.ffgiraldez.comicsearch.query.search.ui
22

3-
import androidx.databinding.BindingAdapter
43
import android.view.View
54
import android.widget.FrameLayout
5+
import android.widget.ProgressBar
66
import android.widget.TextView
7+
import androidx.databinding.BindingAdapter
78
import androidx.recyclerview.widget.RecyclerView
89
import arrow.core.Option
910
import com.arlib.floatingsearchview.FloatingSearchView
1011
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
1112
import es.ffgiraldez.comicsearch.comics.domain.ComicError
1213
import es.ffgiraldez.comicsearch.comics.domain.Volume
14+
import es.ffgiraldez.comicsearch.platform.gone
15+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
1316
import es.ffgiraldez.comicsearch.query.base.ui.OnVolumeSelectedListener
1417
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
1518
import es.ffgiraldez.comicsearch.query.base.ui.QueryVolumeAdapter
19+
import es.ffgiraldez.comicsearch.query.base.ui.error
20+
import es.ffgiraldez.comicsearch.query.base.ui.loading
21+
import es.ffgiraldez.comicsearch.query.base.ui.results
1622
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
1723
import io.reactivex.functions.Consumer
1824

25+
interface ClickConsumer : Consumer<SearchSuggestion>
26+
interface SearchConsumer : Consumer<String>
27+
1928
@BindingAdapter("on_suggestion_click", "on_search", requireAll = false)
2029
fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) {
2130
search.setOnSearchListener(object : FloatingSearchView.OnSearchListener {
@@ -39,30 +48,38 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
3948
/**
4049
* Limit scope to apply using RecyclerView as BindingAdapter
4150
*/
42-
@BindingAdapter("adapter", "on_data_change", "on_selected", requireAll = false)
43-
fun bindData(recycler: RecyclerView, queryAdapter: QueryVolumeAdapter, data: List<Volume>?, consumer: OnVolumeSelectedListener) =
51+
@BindingAdapter("adapter", "state_change", "on_selected", requireAll = false)
52+
fun bindStateData(recycler: RecyclerView, inputAdapter: QueryVolumeAdapter, data: QueryViewState<Volume>?, consumer: OnVolumeSelectedListener) =
4453
with(recycler) {
4554
if (adapter == null) {
46-
adapter = queryAdapter
47-
queryAdapter.onVolumeSelectedListener = consumer
55+
inputAdapter.onVolumeSelectedListener = consumer
56+
adapter = inputAdapter
57+
}
58+
59+
data?.let {
60+
bindError(data.error)
61+
bindResults(data.results)
4862
}
49-
data?.let { queryAdapter.submitList(data) }
5063
}
5164

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 }
65+
@BindingAdapter("state_change")
66+
fun bindStateVisibility(errorContainer: FrameLayout, data: QueryViewState<Volume>?) = data?.let { state ->
67+
state.error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it }
5568
}
5669

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 }
70+
@BindingAdapter("state_change")
71+
fun bindErrorText(errorText: TextView, data: QueryViewState<Volume>?) = data?.let { state ->
72+
state.error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
6073
}
6174

62-
@BindingAdapter("error")
63-
fun bindErrorText(errorText: TextView, errorData: Option<ComicError>?) = errorData?.let { error ->
64-
error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
75+
@BindingAdapter("state_change")
76+
fun bindProgress(progress: ProgressBar, data: QueryViewState<Volume>?) = data?.let { state ->
77+
progress.gone(!state.loading)
6578
}
6679

67-
interface ClickConsumer : Consumer<SearchSuggestion>
68-
interface SearchConsumer : Consumer<String>
80+
private fun RecyclerView.bindError(error: Option<ComicError>): Unit =
81+
error.fold({ View.VISIBLE }, { View.GONE }).let { this.visibility = it }
82+
83+
private fun RecyclerView.bindResults(error: List<Volume>): Unit = with(adapter as QueryVolumeAdapter) {
84+
this.submitList(error)
85+
}

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

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

3-
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewModel
3+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
44
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
55
import es.ffgiraldez.comicsearch.query.sugestion.data.SuggestionRepository
66
import io.reactivex.Flowable
@@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit
99

1010
class SuggestionViewModel private constructor(
1111
transformer: (Flowable<String>) -> Publisher<QueryViewState<String>>
12-
) : QueryViewModel<String>(transformer) {
12+
) : QueryStateViewModel<String>(transformer) {
1313
companion object {
1414
operator fun invoke(repo: SuggestionRepository): SuggestionViewModel = SuggestionViewModel {
1515
it.debounce(400, TimeUnit.MILLISECONDS)
Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,33 @@
11
package es.ffgiraldez.comicsearch.query.sugestion.ui
22

33
import androidx.databinding.BindingAdapter
4-
import arrow.core.Option
54
import com.arlib.floatingsearchview.FloatingSearchView
6-
import es.ffgiraldez.comicsearch.comics.domain.ComicError
7-
import es.ffgiraldez.comicsearch.platform.safe
5+
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
86
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ErrorSuggestion
97
import es.ffgiraldez.comicsearch.query.base.ui.QuerySearchSuggestion.ResultSuggestion
8+
import es.ffgiraldez.comicsearch.query.base.ui.error
9+
import es.ffgiraldez.comicsearch.query.base.ui.loading
10+
import es.ffgiraldez.comicsearch.query.base.ui.results
1011
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
1112

1213
@BindingAdapter("on_change")
13-
fun bindQueryChangeListener(search: FloatingSearchView, listener: FloatingSearchView.OnQueryChangeListener) =
14-
search.setOnQueryChangeListener(listener)
15-
16-
@BindingAdapter("suggestions", "error")
17-
fun bindSuggestions(
14+
fun bindQueryChangeListener(
1815
search: FloatingSearchView,
19-
resultData: List<String>?,
20-
errorData: Option<ComicError>?
21-
) = safe(errorData, resultData) { error, results ->
16+
listener: FloatingSearchView.OnQueryChangeListener
17+
): Unit = search.setOnQueryChangeListener(listener)
18+
19+
@BindingAdapter("state_change")
20+
fun bindSuggestions(search: FloatingSearchView, data: QueryViewState<String>?): Unit? = data?.run {
21+
search.toggleProgress(loading)
2222
error.fold({
2323
results.map { ResultSuggestion(it) }
2424
}, {
2525
listOf(ErrorSuggestion(it.toHumanResponse()))
2626
}).let { search.swapSuggestions(it) }
2727
}
2828

29-
@BindingAdapter("show_progress")
30-
fun bindLoading(search: FloatingSearchView, liveData: Boolean?) = liveData?.let {
31-
when (it) {
32-
true -> search.showProgress()
33-
false -> search.hideProgress()
34-
}
29+
private fun FloatingSearchView.toggleProgress(show: Boolean): Unit = when (show) {
30+
true -> showProgress()
31+
false -> hideProgress()
3532
}
3633

0 commit comments

Comments
 (0)