Skip to content

Commit

Permalink
feat: 🏗️ simplify viewmodel state (#26)
Browse files Browse the repository at this point in the history
- apply state on UI layer for search
- apply state on UI layer for suggestions
- little changes and package movements
  • Loading branch information
ffgiraldez authored Jan 30, 2019
1 parent a00ed21 commit 306f3ae
Show file tree
Hide file tree
Showing 14 changed files with 140 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import io.reactivex.android.MainThreadDisposable
fun <T> LiveData<T>.toFlowable(): Flowable<T> =
Flowable.create({ emitter ->
val observer = Observer<T> {
it?.let { emitter.onNext(it) }
it?.let(emitter::onNext)
}
observeForever(observer)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ 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
}
}
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable

fun <A, B> left(a: A): Either<A, B> = a.left()
fun <A, B> right(b: B): Either<A, B> = b.right()
fun <A, B> right(b: B): Either<A, B> = b.right()

operator fun CompositeDisposable.plus(disposable: Disposable): CompositeDisposable = apply {
add(disposable)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package es.ffgiraldez.comicsearch.platform

import androidx.databinding.BindingAdapter
import android.view.View

@BindingAdapter("gone")
fun bindImage(view: View, gone: Boolean) = with(view) {
fun View.gone(gone: Boolean) = with(this) {
visibility = when (gone) {
true -> View.GONE
false -> View.VISIBLE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package es.ffgiraldez.comicsearch.query.base.presentation


import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import es.ffgiraldez.comicsearch.platform.plus
import es.ffgiraldez.comicsearch.platform.toFlowable
import io.reactivex.Flowable
import io.reactivex.disposables.CompositeDisposable
import org.reactivestreams.Publisher

open class QueryStateViewModel<T>(
transformer: (Flowable<String>) -> Publisher<QueryViewState<T>>
) : ViewModel() {

private val _state: MutableLiveData<QueryViewState<T>> = MutableLiveData()
val state: LiveData<QueryViewState<T>>
get() = _state
val query: MutableLiveData<String> = MutableLiveData()

private val disposable: CompositeDisposable = CompositeDisposable()

init {
disposable + query.toFlowable()
.compose { transformer(it) }
.subscribe { _state.postValue(it) }
}

override fun onCleared(): Unit = disposable.clear()
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ sealed class QueryViewState<out T> {

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>()
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
Expand Up @@ -12,12 +12,12 @@ class QueryScreenDelegate(
val adapter: QueryVolumeAdapter,
private val navigator: Navigator
) {
fun onVolumeSelected(volume: Volume) =
fun onVolumeSelected(volume: Volume): Unit =
navigator.to(Screen.Detail(volume))

fun onQueryChange(new: String) =
fun onQueryChange(new: String): Unit =
with(suggestions) { query.value = new }

fun onSuggestionSelected(suggestion: String) =
fun onSuggestionSelected(suggestion: String): Unit =
with(search) { query.value = suggestion }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package es.ffgiraldez.comicsearch.query.base.ui

import arrow.core.Option
import arrow.core.toOption
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState

val <T>QueryViewState<T>.error: Option<ComicError>
get() = when (this) {
is QueryViewState.Error -> _error.toOption()
else -> Option.empty()
}

val <T>QueryViewState<T>.results: List<T>
get() = when (this) {
is QueryViewState.Result -> _results
else -> emptyList()
}

val <T>QueryViewState<T>.loading: Boolean
get() = when (this) {
is QueryViewState.Loading -> true
else -> false
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import es.ffgiraldez.comicsearch.databinding.QueryItemBinding

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

var onVolumeSelectedListener: OnVolumeSelectedListener? = null
var onVolumeSelectedListener: OnVolumeSelectedListener = OnVolumeSelectedListener.empty

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

override fun onBindViewHolder(holder: QueryVolumeViewHolder, position: Int) {
holder.bind(getItem(position))
holder.itemView.setOnClickListener { onVolumeSelectedListener?.onVolumeSelected(getItem(position)) }
holder.itemView.setOnClickListener { onVolumeSelectedListener.onVolumeSelected(getItem(position)) }
}
}

interface OnVolumeSelectedListener {
companion object {
val empty: OnVolumeSelectedListener = object : OnVolumeSelectedListener {
override fun onVolumeSelected(volume: Volume) {}
}
}

fun onVolumeSelected(volume: Volume)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package es.ffgiraldez.comicsearch.query.search.presentation

import es.ffgiraldez.comicsearch.comics.domain.Volume
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.search.data.SearchRepository
import io.reactivex.Flowable
import org.reactivestreams.Publisher

class SearchViewModel private constructor(
queryToResult: (Flowable<String>) -> Publisher<QueryViewState<Volume>>
) : QueryViewModel<Volume>(queryToResult) {
) : QueryStateViewModel<Volume>(queryToResult) {
companion object {
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel {
operator fun invoke(repo: SearchRepository): SearchViewModel = SearchViewModel { it ->
it.switchMap { handleQuery(repo, it) }
.startWith(QueryViewState.idle())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
package es.ffgiraldez.comicsearch.query.search.ui

import androidx.databinding.BindingAdapter
import android.view.View
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView
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.platform.gone
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
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.error
import es.ffgiraldez.comicsearch.query.base.ui.loading
import es.ffgiraldez.comicsearch.query.base.ui.results
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse
import io.reactivex.functions.Consumer

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

@BindingAdapter("on_suggestion_click", "on_search", requireAll = false)
fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer?, searchConsumer: SearchConsumer?) {
search.setOnSearchListener(object : FloatingSearchView.OnSearchListener {
Expand All @@ -39,30 +48,38 @@ fun bindSuggestionClick(search: FloatingSearchView, clickConsumer: ClickConsumer
/**
* Limit scope to apply using RecyclerView as BindingAdapter
*/
@BindingAdapter("adapter", "on_data_change", "on_selected", requireAll = false)
fun bindData(recycler: RecyclerView, queryAdapter: QueryVolumeAdapter, data: List<Volume>?, consumer: OnVolumeSelectedListener) =
@BindingAdapter("adapter", "state_change", "on_selected", requireAll = false)
fun bindStateData(recycler: RecyclerView, inputAdapter: QueryVolumeAdapter, data: QueryViewState<Volume>?, consumer: OnVolumeSelectedListener) =
with(recycler) {
if (adapter == null) {
adapter = queryAdapter
queryAdapter.onVolumeSelectedListener = consumer
inputAdapter.onVolumeSelectedListener = consumer
adapter = inputAdapter
}

data?.let {
bindError(data.error)
bindResults(data.results)
}
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("state_change")
fun bindStateVisibility(errorContainer: FrameLayout, data: QueryViewState<Volume>?) = data?.let { state ->
state.error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.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("state_change")
fun bindErrorText(errorText: TextView, data: QueryViewState<Volume>?) = data?.let { state ->
state.error.fold({ Unit }, { errorText.text = it.toHumanResponse() })
}

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

interface ClickConsumer : Consumer<SearchSuggestion>
interface SearchConsumer : Consumer<String>
private fun RecyclerView.bindError(error: Option<ComicError>): Unit =
error.fold({ View.VISIBLE }, { View.GONE }).let { this.visibility = it }

private fun RecyclerView.bindResults(error: List<Volume>): Unit = with(adapter as QueryVolumeAdapter) {
this.submitList(error)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package es.ffgiraldez.comicsearch.query.sugestion.presentation

import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryStateViewModel
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
import es.ffgiraldez.comicsearch.query.sugestion.data.SuggestionRepository
import io.reactivex.Flowable
Expand All @@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit

class SuggestionViewModel private constructor(
transformer: (Flowable<String>) -> Publisher<QueryViewState<String>>
) : QueryViewModel<String>(transformer) {
) : QueryStateViewModel<String>(transformer) {
companion object {
operator fun invoke(repo: SuggestionRepository): SuggestionViewModel = SuggestionViewModel {
it.debounce(400, TimeUnit.MILLISECONDS)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,33 @@
package es.ffgiraldez.comicsearch.query.sugestion.ui

import androidx.databinding.BindingAdapter
import arrow.core.Option
import com.arlib.floatingsearchview.FloatingSearchView
import es.ffgiraldez.comicsearch.comics.domain.ComicError
import es.ffgiraldez.comicsearch.platform.safe
import es.ffgiraldez.comicsearch.query.base.presentation.QueryViewState
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.error
import es.ffgiraldez.comicsearch.query.base.ui.loading
import es.ffgiraldez.comicsearch.query.base.ui.results
import es.ffgiraldez.comicsearch.query.base.ui.toHumanResponse

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

@BindingAdapter("suggestions", "error")
fun bindSuggestions(
fun bindQueryChangeListener(
search: FloatingSearchView,
resultData: List<String>?,
errorData: Option<ComicError>?
) = safe(errorData, resultData) { error, results ->
listener: FloatingSearchView.OnQueryChangeListener
): Unit = search.setOnQueryChangeListener(listener)

@BindingAdapter("state_change")
fun bindSuggestions(search: FloatingSearchView, data: QueryViewState<String>?): Unit? = data?.run {
search.toggleProgress(loading)
error.fold({
results.map { ResultSuggestion(it) }
}, {
listOf(ErrorSuggestion(it.toHumanResponse()))
}).let { search.swapSuggestions(it) }
}

@BindingAdapter("show_progress")
fun bindLoading(search: FloatingSearchView, liveData: Boolean?) = liveData?.let {
when (it) {
true -> search.showProgress()
false -> search.hideProgress()
}
private fun FloatingSearchView.toggleProgress(show: Boolean): Unit = when (show) {
true -> showProgress()
false -> hideProgress()
}

Loading

0 comments on commit 306f3ae

Please sign in to comment.