diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/comics/data/ComicRepository.kt b/app/src/main/java/es/ffgiraldez/comicsearch/comics/data/ComicRepository.kt index 52cfc66..33726d3 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/comics/data/ComicRepository.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/comics/data/ComicRepository.kt @@ -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( private val local: ComicLocalDataSource, private val remote: ComicRemoteDataSource ) { - fun findByTerm(term: String): Flowable> = + fun findByTerm(term: String): Flowable>> = local.findQueryByTerm(term) - .flatMap { - when (it) { - is None -> remote.findByTerm(term) - .flatMapPublisher { local.insert(term, it).toFlowable>() } - is Some -> local.findByQuery(it.t) + .flatMap { findSuggestions(it, term) } + + private fun findSuggestions( + query: Option, + term: String + ): Flowable>> = when (query) { + is None -> searchSuggestions(term) + is Some -> fetchSuggestions(query) + } + + private fun searchSuggestions(term: String): Flowable>> = + remote.findByTerm(term) + .map { right>(it) } + .onErrorReturn { left>(NetworkError) } + .flatMapPublisher { saveSuggestions(it, term) } + + private fun saveSuggestions( + results: Either>, + term: String + ): Flowable>> = + results.fold({ _ -> + Flowable.just(results) + }, { + local.insert(term, it).toFlowable>>() + }) + + private fun fetchSuggestions(it: Some): Flowable>> = + local.findByQuery(it.t) + .map { + when (it.isEmpty()) { + true -> Either.left(EmptyResultsError) + false -> Either.right(it) } } } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/comics/di/comicModule.kt b/app/src/main/java/es/ffgiraldez/comicsearch/comics/di/comicModule.kt index f4743ea..db20e1e 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/comics/di/comicModule.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/comics/di/comicModule.kt @@ -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 { diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/comics/domain/Entities.kt b/app/src/main/java/es/ffgiraldez/comicsearch/comics/domain/Entities.kt index c39b015..95ce722 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/comics/domain/Entities.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/comics/domain/Entities.kt @@ -9,4 +9,9 @@ data class Volume( data class Query( val identifier: Long, val searchTerm: String -) \ No newline at end of file +) + +sealed class ComicError { + object NetworkError : ComicError() + object EmptyResultsError : ComicError() +} \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/navigation/di/androidModule.kt b/app/src/main/java/es/ffgiraldez/comicsearch/navigation/di/androidModule.kt index fa14b2d..9a8b77f 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/navigation/di/androidModule.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/navigation/di/androidModule.kt @@ -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]) } diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/platform/Utilities.kt b/app/src/main/java/es/ffgiraldez/comicsearch/platform/Utilities.kt new file mode 100644 index 0000000..39b8dec --- /dev/null +++ b/app/src/main/java/es/ffgiraldez/comicsearch/platform/Utilities.kt @@ -0,0 +1,16 @@ +package es.ffgiraldez.comicsearch.platform + +import arrow.core.Either +import arrow.core.left +import arrow.core.right + +fun safe(first: A?, second: B?, block: (A, B) -> C): C? { + return if (first != null && second != null) { + block(first, second) + } else { + null + } +} + +fun left(a: A): Either = a.left() +fun right(b: B): Either = b.right() \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewModel.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewModel.kt index a1bcd37..3842bf9 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewModel.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewModel.kt @@ -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 @@ -11,6 +15,7 @@ open class QueryViewModel( ) : ViewModel() { val query: MutableLiveData = MutableLiveData() + val error: MutableLiveData> = MutableLiveData() val loading: MutableLiveData = MutableLiveData() val results: MutableLiveData> = MutableLiveData() @@ -19,17 +24,17 @@ open class QueryViewModel( .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) { + private fun applyState(isLoading: Boolean, results: List = emptyList(), error: Option = none()) { this.loading.postValue(isLoading) this.results.postValue(results) + this.error.postValue(error) } } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewState.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewState.kt index b0129b2..c219a1c 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewState.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/presentation/QueryViewState.kt @@ -1,15 +1,19 @@ package es.ffgiraldez.comicsearch.query.base.presentation +import es.ffgiraldez.comicsearch.comics.domain.ComicError + sealed class QueryViewState { companion object { fun result(volumeList: List): QueryViewState = Result(volumeList) fun idle(): QueryViewState = Idle fun loading(): QueryViewState = Loading + fun error(error: ComicError): QueryViewState = Error(error) } object Idle : QueryViewState() object Loading : QueryViewState() data class Result(val results: List) : QueryViewState() + data class Error(val error: ComicError) : QueryViewState() } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/ComicErrorBindingAdapter.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/ComicErrorBindingAdapter.kt new file mode 100644 index 0000000..80f84f3 --- /dev/null +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/ComicErrorBindingAdapter.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QueryActivity.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QueryActivity.kt index c4cf5d5..94dc84d 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QueryActivity.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QueryActivity.kt @@ -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 diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt index faa40cb..8871676 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/base/ui/QuerySearchSuggestion.kt @@ -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) + } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt index 58c0773..b17e425 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/presentation/SearchViewModel.kt @@ -12,11 +12,19 @@ class SearchViewModel private constructor( ) : QueryViewModel(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> = repo.findByTerm(it) - .map { QueryViewState.result(it) } + .map { + it.fold({ + QueryViewState.error(it) + }, { + QueryViewState.result(it) + }) + } .startWith(QueryViewState.loading()) - }.startWith(QueryViewState.idle()) - } } } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt index e005f28..bb94c2a 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/search/ui/SearchBindingAdapters.kt @@ -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) @@ -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) + } + } } } }) @@ -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?) = errorData?.let { error -> + error.fold({ View.VISIBLE }, { View.GONE }).let { recycler.visibility = it } +} + +@BindingAdapter("error") +fun bindErrorVisibility(errorContainer: FrameLayout, errorData: Option?) = errorData?.let { error -> + error.fold({ View.GONE }, { View.VISIBLE }).let { errorContainer.visibility = it } +} + +@BindingAdapter("error") +fun bindErrorText(errorText: TextView, errorData: Option?) = errorData?.let { error -> + error.fold({ Unit }, { errorText.text = it.toHumanResponse() }) +} interface ClickConsumer : Consumer interface SearchConsumer : Consumer \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/data/SuggestionRepository.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/data/SuggestionRepository.kt index e1a4854..4875259 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/data/SuggestionRepository.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/data/SuggestionRepository.kt @@ -5,4 +5,4 @@ import es.ffgiraldez.comicsearch.comics.data.ComicRepository class SuggestionRepository( local: SuggestionLocalDataSource, remote: SuggestionRemoteDataSource -) : ComicRepository(local, remote) \ No newline at end of file +) : ComicRepository(local, remote) diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt index ee0fc6d..f4d867c 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/presentation/SuggestionViewModel.kt @@ -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> = + if (query.isEmpty()) { + Flowable.just(QueryViewState.idle()) + } else { + searchSuggestions(repo, query) + } + + private fun searchSuggestions( + repo: SuggestionRepository, + query: String + ): Flowable> = + repo.findByTerm(query) + .map { suggestions -> + suggestions.fold({ + QueryViewState.error(it) + }, { + QueryViewState.result(it) + }) + }.startWith(QueryViewState.loading()) } } \ No newline at end of file diff --git a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt index 233c868..6dab629 100644 --- a/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt +++ b/app/src/main/java/es/ffgiraldez/comicsearch/query/sugestion/ui/SuggestionBindingAdapters.kt @@ -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?) = liveData?.let { - it.map { QuerySearchSuggestion(it) }.let { search.swapSuggestions(it) } +@BindingAdapter("suggestions", "error") +fun bindSuggestions( + search: FloatingSearchView, + resultData: List?, + errorData: Option? +) = safe(errorData, resultData) { error, results -> + error.fold({ + results.map { ResultSuggestion(it) } + }, { + listOf(ErrorSuggestion(it.toHumanResponse())) + }).let { search.swapSuggestions(it) } } @BindingAdapter("show_progress") @@ -20,4 +33,5 @@ fun bindLoading(search: FloatingSearchView, liveData: Boolean?) = liveData?.let true -> search.showProgress() false -> search.hideProgress() } -} \ No newline at end of file +} + diff --git a/app/src/main/res/layout/query_activity.xml b/app/src/main/res/layout/query_activity.xml index a351623..1cf2ebd 100644 --- a/app/src/main/res/layout/query_activity.xml +++ b/app/src/main/res/layout/query_activity.xml @@ -15,6 +15,18 @@ android:layout_height="match_parent" android:fitsSystemWindows="true"> + + + + + @@ -47,6 +60,7 @@ app:on_change='@{ (old, new) -> delegate.onQueryChange(new) }' app:on_suggestion_click='@{ (suggestion) -> delegate.onSuggestionSelected(suggestion.body) }' app:show_progress='@{ delegate.suggestions.loading }' + app:error='@{ delegate.suggestions.error}' app:suggestions='@{ delegate.suggestions.results }' /> diff --git a/app/src/test/java/es/ffgiraldez/comicsearch/di/TestContextResolution.kt b/app/src/test/java/es/ffgiraldez/comicsearch/di/TestContextResolution.kt index 3aabebd..6f1e952 100644 --- a/app/src/test/java/es/ffgiraldez/comicsearch/di/TestContextResolution.kt +++ b/app/src/test/java/es/ffgiraldez/comicsearch/di/TestContextResolution.kt @@ -4,9 +4,9 @@ import android.app.Activity import android.arch.core.executor.testing.InstantTaskExecutorRule import android.content.Context import com.nhaarman.mockito_kotlin.mock +import es.ffgiraldez.comicsearch.comics.di.CONTEXT_PARAM import es.ffgiraldez.comicsearch.comics.di.comicModule import es.ffgiraldez.comicsearch.navigation.di.ACTIVITY_PARAM -import es.ffgiraldez.comicsearch.navigation.di.CONTEXT_PARAM import es.ffgiraldez.comicsearch.navigation.di.navigationModule import es.ffgiraldez.comicsearch.query.search.di.searchModule import es.ffgiraldez.comicsearch.query.sugestion.di.suggestionModule