diff --git a/ModelViewIntentSample/app/src/main/AndroidManifest.xml b/ModelViewIntentSample/app/src/main/AndroidManifest.xml index 99b91060..39f447ae 100644 --- a/ModelViewIntentSample/app/src/main/AndroidManifest.xml +++ b/ModelViewIntentSample/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ android:supportsRtl="true" android:theme="@style/AppTheme" android:name=".MoviesApplication"> - + diff --git a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt new file mode 100644 index 00000000..be8abafb --- /dev/null +++ b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/BaseStore.kt @@ -0,0 +1,56 @@ +package com.novoda.movies.mvi.search + +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.subjects.BehaviorSubject +import io.reactivex.subjects.PublishSubject + +class BaseStore( + private val schedulingStrategy: SchedulingStrategy, + private val reducer: Reducer, + private val middlewares: List>, + private val initialValue: S +) : Store { + private val changes = PublishSubject.create() + private val state = BehaviorSubject.createDefault(initialValue) + private val actions: PublishSubject = PublishSubject.create() + + override fun wire(): Disposable { + val disposables = CompositeDisposable() + + val newState = changes.scan( + initialValue, { state, change -> + reducer.reduce(state, change) + } + ) + + disposables.add( + newState + .subscribeOn(schedulingStrategy.work) + .subscribe(state::onNext) + ) + + for (middleware in middlewares) { + val observable = middleware + .bind(actions, state) + .subscribeOn(schedulingStrategy.work) + disposables.add(observable.subscribe(changes::onNext)) + } + + return disposables + } + + override fun bind(displayer: Displayer): Disposable { + val disposables = CompositeDisposable() + + disposables.add(displayer.actions.subscribe(actions::onNext)) + + disposables.add( + state + .observeOn(schedulingStrategy.ui) + .subscribe(displayer::render) + ) + + return disposables + } +} \ No newline at end of file diff --git a/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt new file mode 100644 index 00000000..506adb17 --- /dev/null +++ b/ModelViewIntentSample/core/src/main/java/com/novoda/movies/mvi/search/MVI.kt @@ -0,0 +1,22 @@ +package com.novoda.movies.mvi.search + +import io.reactivex.Observable +import io.reactivex.disposables.Disposable + +interface Displayer { + val actions: Observable + fun render(state: S) +} + +interface Reducer { + fun reduce(state: S, change: C): S +} + +interface Middleware { + fun bind(actions: Observable, state: Observable): Observable +} + +interface Store { + fun wire(): Disposable + fun bind(displayer: Displayer): Disposable +} diff --git a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt b/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt deleted file mode 100644 index e0248de2..00000000 --- a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.novoda.movies.mvi.search - -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - - -internal class ExampleTest { - - @Test - fun `should assert sth`() { - val foo = mock() - whenever(foo.bar()).thenReturn("bar2") - - assertThat(foo.bar()).isEqualTo("bar2") - } - - class Foo { - fun bar(): String = "bar" - } -} diff --git a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt b/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt deleted file mode 100644 index 9b593d93..00000000 --- a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.novoda.movies.mvi.search.results - -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever -import com.novoda.movies.mvi.search.SearchResults -import com.novoda.movies.mvi.search.SearchResultsModel.State.* -import com.novoda.movies.mvi.search.api.SearchBackend -import io.reactivex.Single -import org.junit.Test - -class RealSearchResultsModelTest { - - private val queryString = "gateau" - private val backend: SearchBackend = mock() - - - @Test - fun `should emit initial state without initial query when initial query empty`() { - model() - .state() - .test() - .assertValue(Initial) - } - - @Test - fun `should show text input when user changes query`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - val state = model.state().test() - - model.queryChanged(queryString) - - state.assertValues( - Initial, - TextInput(queryString) - ) - } - - @Test - fun `should start with loading`() { - givenBackendReturn(Single.never()) - val model = model() - val state = model.state() - - model.queryChanged(queryString) - model.executeSearch() - - state.test() - .assertValue(Loading(queryString)) - } - - @Test - fun `should emit result after loading`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - val state = model.state().test() - - model.queryChanged(queryString) - model.executeSearch() - - state.assertValues( - Initial, - TextInput(queryString), - Loading(queryString), - Content(queryString, searchResults) - ) - } - - @Test - fun `should emit latest state`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - - model.queryChanged(queryString) - model.executeSearch() - - model.state() - .test() - .assertValues(Content(queryString, searchResults)) - } - - @Test - fun `should emit error when search failed`() { - val exception = Exception() - givenBackendFailWith(exception) - val model = model() - - model.queryChanged(queryString) - model.executeSearch() - - model.state() - .test() - .assertValue(Error(queryString, exception)) - } - - @Test - fun `should recover from error`() { - val exception = Exception() - givenBackendFailWith(exception) - val model = model() - val state = model.state().test() - model.queryChanged(queryString) - model.executeSearch() - - val searchResults = results() - givenBackendSucceedWith(searchResults) - model.executeSearch() - - state.assertValues( - Initial, - TextInput(queryString), - Loading(queryString), - Error(queryString, exception), - Loading(queryString), - Content(queryString, searchResults) - ) - } - - private fun model(): RealSearchResultsModel { - return RealSearchResultsModel( - backend, - SynchronousSchedulingStrategy() - ) - } - - private fun results(): SearchResults = SearchResults( - listOf(mock(), mock()) - ) - - private fun givenBackendSucceedWith(searchResults: SearchResults) { - givenBackendReturn(Single.just(searchResults)) - } - - private fun givenBackendReturn(single: Single) { - whenever(backend.search(queryString)).thenReturn(single) - } - - private fun givenBackendFailWith(exception: Exception) { - givenBackendReturn(Single.error(exception)) - } -} diff --git a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt b/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt deleted file mode 100644 index 484b0691..00000000 --- a/ModelViewIntentSample/core/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.novoda.movies.mvi.search.results - -import com.novoda.movies.mvi.search.SchedulingStrategy -import io.reactivex.Scheduler -import io.reactivex.schedulers.Schedulers - -class SynchronousSchedulingStrategy : SchedulingStrategy { - override val work: Scheduler = Schedulers.trampoline() - override val ui: Scheduler = Schedulers.trampoline() -} diff --git a/ModelViewIntentSample/high_level_diagram.png b/ModelViewIntentSample/high_level_diagram.png new file mode 100644 index 00000000..d32f9946 Binary files /dev/null and b/ModelViewIntentSample/high_level_diagram.png differ diff --git a/ModelViewIntentSample/readme.md b/ModelViewIntentSample/readme.md new file mode 100644 index 00000000..70325e54 --- /dev/null +++ b/ModelViewIntentSample/readme.md @@ -0,0 +1,45 @@ +# Model-View-Intent Sample + +The goal of this project is to showcase a reactive implementation of [model-view-intent](http://hannesdorfmann.com/android/model-view-intent). + +The core of this MVI implementation is an unidirectional data flow where user intents are processed and mapped to view states. The view state, in this example, is an immutable value object representing the different states of one screen. + +# Components + +In order to make this implementation reusable we extracted a couple of generic components: + +A **MVIView** is UI component which exposes a stream of intents, which we call actions in this project to not confuse with android intents, and can render a view state. + +The **Middleware** processes these actions using domain specific business rules and emits a stream of changes. + +The **Reducer** consumes use-case specific changes from the Middleware and converts them to a view state. + +The **Store** is the glue between the above components and is mainly responsible for forwarding events between these. + +## MVIView < Action > + +In this concrete implementation the MVIView is a wrapper around the Activity which collaborates between multiple custom views. It merges and exposes user actions from all custom view and is capable of rendering a view state, without performing any further logic. + +For example a `SearchInputView` will emit a `SubmitQuery:Action`. + +## Middleware + +The Middlewares implement the domain specific business rules. They operate on the user actions and map them to domain specific change events. + +A `SearchMiddleware` would consume an `SubmitQuery:Action` and will perform a asynchronous operation to execute a search and map the result to a `SearchCompleted:Change`. +The idea is to have multiple middlewares for the different aspects of the domain and business requirements (e.g. tracking). + +## Reducer + +The Reducer consumes the use-case specific changes and maps them to a view-state. This means multiple use-case specific actions (`SearchInProgress, FilterInProgress`) might end up in the same view state indicating progress on the screen. + +Following the above example the `SearchReducer` would map `SearchCompleted:Change` to a view state containing the data needed to render search results. + +## Store + +The Store is the glue between the above mentioned components. It listens to user actions, forwards them to all middlewares, forwards their changes to the reducer and passes the generated view state back to the view. + +# Diagrams +| High-level Diagram|Sequence Diagram| +|----|----| +|![MainView](https://user-images.githubusercontent.com/1046688/61949720-071baa00-afac-11e9-96e1-4e68c5b0844e.png)| ![Untitled](https://user-images.githubusercontent.com/1046688/61949761-20245b00-afac-11e9-94ab-bf51764b6cca.png) | diff --git a/ModelViewIntentSample/search/build.gradle b/ModelViewIntentSample/search/build.gradle index 1f3a1e15..5824a41a 100644 --- a/ModelViewIntentSample/search/build.gradle +++ b/ModelViewIntentSample/search/build.gradle @@ -48,4 +48,5 @@ dependencies { testImplementation libraries.test.mockitoKotlin testImplementation libraries.test.mockitoInline testImplementation libraries.test.assertj + implementation 'android.arch.lifecycle:extensions:1.1.1' } diff --git a/ModelViewIntentSample/search/src/main/AndroidManifest.xml b/ModelViewIntentSample/search/src/main/AndroidManifest.xml index 7593d167..389d4cfa 100644 --- a/ModelViewIntentSample/search/src/main/AndroidManifest.xml +++ b/ModelViewIntentSample/search/src/main/AndroidManifest.xml @@ -9,8 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> - + diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/RealSearchResultsModel.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/RealSearchResultsModel.kt deleted file mode 100644 index f86002bf..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/RealSearchResultsModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.novoda.movies.mvi.search - -import com.novoda.movies.mvi.search.SearchResultsModel.State -import com.novoda.movies.mvi.search.SearchResultsModel.State.* -import com.novoda.movies.mvi.search.api.SearchBackend -import io.reactivex.Observable -import io.reactivex.functions.BiFunction -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.PublishSubject -import io.reactivex.subjects.Subject - -internal class RealSearchResultsModel( - private val backend: SearchBackend, - private val schedulingStrategy: SchedulingStrategy -) : SearchResultsModel { - private val state: Subject = BehaviorSubject.createDefault(Initial) - - private val actions: Subject = PublishSubject.create() - init { - actions - .withLatestFrom(state, actionToState()) - .switchMap { (action, state) -> - handle(action, state) - }.subscribe(state) - - } - - private fun actionToState(): BiFunction> { - return BiFunction { action, state -> - action to state - } - } - - private fun handle(action: Action, state: State): Observable { - return when (action) { - is Action.ChangeQuery -> textInput(action.queryString) - is Action.ClearQuery -> textInputWithEmptyQuery() - is Action.ExecuteSearch -> processQuery(state.queryString) - } - } - - private fun textInputWithEmptyQuery(): Observable = - Observable.just(TextInput("") as State) - - private fun textInput(queryString: String) = - Observable.just(TextInput(queryString) as State) - - private fun processQuery(query: String): Observable { - return backend.search(query) - .toObservable() - .map { results -> Content(query, results) as State } - .startWith(Loading(query)) - .onErrorReturn { throwable -> Error(query, throwable) } - .subscribeOn(schedulingStrategy.work) - } - - override fun state(): Observable = state - .subscribeOn(schedulingStrategy.work) - .observeOn(schedulingStrategy.ui) - - override fun executeSearch() = actions.onNext(Action.ExecuteSearch) - - override fun queryChanged(queryString: String) = - actions.onNext(Action.ChangeQuery(queryString)) - - override fun clearQuery() { - actions.onNext(Action.ClearQuery) - } - - private sealed class Action { - data class ChangeQuery(val queryString: String) : Action() - object ExecuteSearch : Action() - object ClearQuery : Action() - } -} - diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchActivity.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchActivity.kt deleted file mode 100644 index 0f4ddc48..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchActivity.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.novoda.movies.mvi.search - -import android.os.Bundle -import android.support.v7.app.AppCompatActivity -import com.novoda.movies.mvi.search.view.SearchInputView -import com.novoda.movies.mvi.search.view.SearchResultsView -import kotlinx.android.synthetic.main.activity_search.* - -internal class SearchActivity : AppCompatActivity() { - - private lateinit var searchInput: SearchInputView - private lateinit var resultsView: SearchResultsView - - lateinit var presenter: SearchResultsPresenter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - Injector().inject(this) - setContentView(R.layout.activity_search) - searchInput = search_input - resultsView = search_results - } - - override fun onStart() { - super.onStart() - presenter.bind(searchInput, resultsView) - } - - override fun onStop() { - super.onStop() - presenter.unbind() - } - - class Injector { - fun inject(searchActivity: SearchActivity) { - val dependencies = searchActivity.application as Dependencies - val networkDependencyProvider = dependencies.networkDependencyProvider - val searchDependencyProvider = SearchDependencyProvider(networkDependencyProvider, dependencies.endpoints) - searchActivity.presenter = searchDependencyProvider.provideSearchResultsPresenter() - } - } -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchDependencyProvider.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchDependencyProvider.kt deleted file mode 100644 index 29c4d4b7..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchDependencyProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.novoda.movies.mvi.search - -import com.novoda.movies.mvi.search.api.ApiSearchResultsConverter -import com.novoda.movies.mvi.search.api.SearchApi -import com.novoda.movies.mvi.search.api.SearchBackend -import com.novoda.movies.mvi.search.view.SearchResultsConverter - -internal class SearchDependencyProvider( - private val networkDependencyProvider: NetworkDependencyProvider, - private val endpoints: Endpoints -) { - - private fun provideSearchResultsModel(): SearchResultsModel { - return RealSearchResultsModel( - provideSearchBackend(), - ProductionSchedulingStrategy() - ) - } - - private fun provideSearchBackend(): SearchBackend { - val searchApi = networkDependencyProvider.provideRetrofit().create(SearchApi::class.java) - return SearchBackend( - searchApi, - ApiSearchResultsConverter(endpoints) - ) - } - - fun provideSearchResultsPresenter(): SearchResultsPresenter { - return SearchResultsPresenter( - provideSearchResultsModel(), - SearchResultsConverter() - ) - } -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResults.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResults.kt deleted file mode 100644 index a0539610..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResults.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.novoda.movies.mvi.search - -import java.net.URL - -internal data class SearchResults( - val items: List, - val totalResults: Int -) - -internal data class SearchResultItem( - val id: Int, - val title: String, - val posterPath: URL -) diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsModel.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsModel.kt deleted file mode 100644 index af28ee1f..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsModel.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.novoda.movies.mvi.search - -import io.reactivex.Observable - - -internal interface SearchResultsModel { - - fun state(): Observable - fun executeSearch() - fun queryChanged(queryString: String) - fun clearQuery() - - sealed class State { - abstract val queryString: String - - object Initial : State() { - override val queryString: String - get() = "" - } - - data class TextInput(override val queryString: String) : State() - data class Content( - override val queryString: String, - val searchResults: SearchResults - ) : State() - - data class Loading(override val queryString: String) : State() - data class Error(override val queryString: String, val throwable: Throwable) : State() - } -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsPresenter.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsPresenter.kt deleted file mode 100644 index 92c30d7e..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/SearchResultsPresenter.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.novoda.movies.mvi.search - -import android.util.Log -import com.novoda.movies.mvi.search.SearchResultsModel.State -import com.novoda.movies.mvi.search.view.SearchInputViewable -import com.novoda.movies.mvi.search.view.SearchResultsConverter -import com.novoda.movies.mvi.search.view.SearchResultsViewable -import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.disposables.Disposable - -internal class SearchResultsPresenter( - private val model: SearchResultsModel, - private val converter: SearchResultsConverter -) { - private val disposables = CompositeDisposable() - - fun bind( - inputView: SearchInputViewable, - resultsView: SearchResultsViewable - ) { - inputView.onQuerySubmitted = ::executeSearch - inputView.onQueryCleared = ::clearQuery - inputView.onQueryChanged = ::changeQuery - - model.state() - .subscribeToState( - onTextInput = { - resultsView.showTextInput() - }, - onContent = { - displayResults(it, resultsView) - }, - onInitial = { - showInitial(inputView, resultsView) - }, - onEachState = { - renderInput(it.queryString, inputView) - }, - onError = { - Log.e("Movies", "Error", it) - }, - onLoading = { - Log.i("Movies", "Loading Movies") - } - ).disposedBy(disposables) - } - - private fun clearQuery() = model.clearQuery() - - private fun executeSearch() = model.executeSearch() - - private fun changeQuery(queryString: String) = model.queryChanged(queryString) - - private fun showInitial(inputView: SearchInputViewable, resultsView: SearchResultsViewable) { - resultsView.showTextInput() - inputView.showKeyboard() - } - - private fun displayResults(state: State.Content, resultsView: SearchResultsViewable) { - val viewResults = converter.convert(state.searchResults) - if (viewResults.totalItemCount > 0) { - resultsView.showResults(viewResults) - } else { - resultsView.showNoResults(state.queryString) - } - } - - private fun renderInput(queryString: String, inputView: SearchInputViewable) { - inputView.currentQuery = queryString - } - - fun unbind() { - disposables.clear() - } -} - -@Suppress("LongParameterList") -private fun Observable.subscribeToState( - onTextInput: () -> Unit, - onContent: (State.Content) -> Unit, - onLoading: () -> Unit, - onError: (throwable: Throwable) -> Unit, - onInitial: () -> Unit, - onEachState: (State) -> Unit -): Disposable { - return subscribe { state -> - when (state) { - is State.TextInput -> onTextInput() - is State.Content -> onContent(state) - is State.Loading -> onLoading() - is State.Error -> onError(state.throwable) - is State.Initial -> onInitial() - } - onEachState(state) - } -} - -fun Disposable.disposedBy(compositeDisposable: CompositeDisposable): Disposable = - apply { compositeDisposable.add(this) } - diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchBackend.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchBackend.kt deleted file mode 100644 index 5922d972..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchBackend.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.novoda.movies.mvi.search.api - -import com.novoda.movies.mvi.search.SearchResults -import io.reactivex.Single - -internal class SearchBackend( - private val searchApi: SearchApi, - private val searchConverter: ApiSearchResultsConverter -) { - - fun search(query: String): Single { - return searchApi - .search(query) - .map(searchConverter::convert) - } -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResults.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResults.kt similarity index 84% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResults.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResults.kt index 0bb97278..a866d43a 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResults.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResults.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.api +package com.novoda.movies.mvi.search.data internal data class ApiSearchResults( diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResultsConverter.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt similarity index 82% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResultsConverter.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt index c154056b..815f658d 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/ApiSearchResultsConverter.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/ApiSearchResultsConverter.kt @@ -1,8 +1,8 @@ -package com.novoda.movies.mvi.search.api +package com.novoda.movies.mvi.search.data import com.novoda.movies.mvi.search.Endpoints -import com.novoda.movies.mvi.search.SearchResultItem -import com.novoda.movies.mvi.search.SearchResults +import com.novoda.movies.mvi.search.domain.SearchResultItem +import com.novoda.movies.mvi.search.domain.SearchResults import java.net.URL internal class ApiSearchResultsConverter( @@ -13,7 +13,6 @@ internal class ApiSearchResultsConverter( return apiSearchResults.toSearchResults() } - private fun ApiSearchResults.toSearchResults(): SearchResults { return SearchResults( items = results.map { diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchApi.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchApi.kt similarity index 84% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchApi.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchApi.kt index a2acc592..0b73166d 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/api/SearchApi.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchApi.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.api +package com.novoda.movies.mvi.search.data import io.reactivex.Single import retrofit2.http.GET diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt new file mode 100644 index 00000000..8f9f6456 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/data/SearchBackend.kt @@ -0,0 +1,16 @@ +package com.novoda.movies.mvi.search.data + +import com.novoda.movies.mvi.search.domain.SearchResults +import io.reactivex.Single + +internal class SearchBackend( + private val searchApi: SearchApi, + private val searchConverter: ApiSearchResultsConverter +) { + + fun search(query: String): Single { + return searchApi + .search(query) + .map(searchConverter::convert) + } +} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt new file mode 100644 index 00000000..516ca809 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchDependencyProvider.kt @@ -0,0 +1,38 @@ +package com.novoda.movies.mvi.search.domain + +import com.novoda.movies.mvi.search.BaseStore +import com.novoda.movies.mvi.search.Endpoints +import com.novoda.movies.mvi.search.NetworkDependencyProvider +import com.novoda.movies.mvi.search.ProductionSchedulingStrategy +import com.novoda.movies.mvi.search.data.ApiSearchResultsConverter +import com.novoda.movies.mvi.search.data.SearchApi +import com.novoda.movies.mvi.search.data.SearchBackend +import com.novoda.movies.mvi.search.presentation.SearchResultsConverter +import com.novoda.movies.mvi.search.presentation.SearchStore +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State +import com.novoda.movies.mvi.search.presentation.ViewSearchResults + +internal class SearchDependencyProvider( + private val networkDependencyProvider: NetworkDependencyProvider, + private val endpoints: Endpoints +) { + + private fun provideSearchBackend(): SearchBackend { + val searchApi = networkDependencyProvider.provideRetrofit().create(SearchApi::class.java) + return SearchBackend( + searchApi, + ApiSearchResultsConverter(endpoints) + ) + } + + fun provideSearchStore(): SearchStore { + return BaseStore( + reducer = SearchReducer(provideSearchResultsConverter()), + schedulingStrategy = ProductionSchedulingStrategy(), + middlewares = listOf(SearchMiddleware(provideSearchBackend(), ProductionSchedulingStrategy().work)), + initialValue = State(queryString = "", results = ViewSearchResults.emptyResults) + ) + } + + private fun provideSearchResultsConverter() = SearchResultsConverter() +} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt new file mode 100644 index 00000000..a863fd54 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchMiddleware.kt @@ -0,0 +1,54 @@ +package com.novoda.movies.mvi.search.domain + +import com.novoda.movies.mvi.search.Middleware +import com.novoda.movies.mvi.search.data.SearchBackend +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes.* +import com.novoda.movies.mvi.search.presentation.SearchViewModel +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action.* +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State +import io.reactivex.Observable +import io.reactivex.Scheduler +import io.reactivex.functions.BiFunction + +internal class SearchMiddleware( + private val backend: SearchBackend, + private val workScheduler: Scheduler +) : Middleware { + + override fun bind(actions: Observable, state: Observable): Observable { + return actions + .withLatestFrom(state, actionToState()) + .switchMap { (action, state) -> handle(action, state) } + } + + private fun actionToState(): BiFunction> = + BiFunction { action, state -> action to state } + + private fun handle(action: SearchViewModel.Action, state: State): Observable = + when (action) { + is ChangeQuery -> Observable.just(UpdateSearchQuery(action.queryString)) + is ExecuteSearch -> processAction(state) + is ClearQuery -> processClearQuery() + } + + private fun processClearQuery(): Observable { + val updateQuery = Observable.just(UpdateSearchQuery("") as Changes) + val removeResults = Observable.just(RemoveResults) + return updateQuery.concatWith(removeResults) + } + + private fun processAction(state: State): Observable { + val loadContent = backend.search(state.queryString) + .toObservable() + .map { searchResult -> AddResults(searchResult) as Changes } + .startWith(ShowProgress) + .onErrorReturn { throwable -> HandleError(throwable) } + val hideProgress = Observable.just(HideProgress) + + return loadContent + .concatWith(hideProgress) + .subscribeOn(workScheduler) + } +} + diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt new file mode 100644 index 00000000..3d962170 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchReducer.kt @@ -0,0 +1,56 @@ +package com.novoda.movies.mvi.search.domain + +import com.novoda.movies.mvi.search.Reducer +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes.* +import com.novoda.movies.mvi.search.presentation.SearchResultsConverter +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State +import com.novoda.movies.mvi.search.presentation.ViewSearchResults + +internal class SearchReducer( + private val searchResultsConverter: SearchResultsConverter +) : Reducer { + + override fun reduce(state: State, change: Changes): State = + when (change) { + is ShowProgress -> state.showLoading() + is HideProgress -> state.hideLoading() + is AddResults -> state.addResults(change.results) + is RemoveResults -> state.removeResults() + is UpdateSearchQuery -> state.updateQuery(change.queryString) + is HandleError -> state.toError(change.throwable) + } + + private fun State.addResults(results: SearchResults): State { + return copy(results = searchResultsConverter.convert(results)) + } + + sealed class Changes { + object ShowProgress : Changes() + object HideProgress : Changes() + data class AddResults(val results: SearchResults) : Changes() + object RemoveResults : Changes() + data class HandleError(val throwable: Throwable) : Changes() + data class UpdateSearchQuery(val queryString: String) : Changes() + } + +} + +private fun State.removeResults(): State { + return copy(results = ViewSearchResults.emptyResults) +} + +private fun State.toError(throwable: Throwable): State { + return copy(error = throwable) +} + +private fun State.updateQuery(queryString: String): State { + return copy(queryString = queryString) +} + +private fun State.hideLoading(): State { + return copy(loading = false) +} + +private fun State.showLoading(): State { + return copy(loading = true) +} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchResults.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchResults.kt new file mode 100644 index 00000000..bde0bd55 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/domain/SearchResults.kt @@ -0,0 +1,14 @@ +package com.novoda.movies.mvi.search.domain + +import java.net.URL + +data class SearchResults( + val items: List = listOf(), + val totalResults: Int = 0 +) + +data class SearchResultItem( + val id: Int, + val title: String, + val posterPath: URL +) diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt new file mode 100644 index 00000000..3ccd97a7 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchActivity.kt @@ -0,0 +1,75 @@ +package com.novoda.movies.mvi.search.presentation + +import android.arch.lifecycle.ViewModel +import android.arch.lifecycle.ViewModelProvider +import android.arch.lifecycle.ViewModelProviders +import android.os.Bundle +import android.support.v7.app.AppCompatActivity +import android.util.Log +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import com.novoda.movies.mvi.search.Dependencies +import com.novoda.movies.mvi.search.Displayer +import com.novoda.movies.mvi.search.R +import com.novoda.movies.mvi.search.domain.SearchDependencyProvider +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State +import io.reactivex.Observable +import kotlinx.android.synthetic.main.activity_search.* + +internal class SearchActivity : AppCompatActivity(), + Displayer { + + private lateinit var searchInput: SearchInputView + private lateinit var resultsView: SearchResultsView + + private lateinit var viewModel: SearchViewModel + + override val actions: Observable + get() = searchInput.actions + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProviders.of(this, SearchViewModelFactory(this)) + .get(SearchViewModel::class.java) + setContentView(R.layout.activity_search) + searchInput = search_input + resultsView = search_results + } + + override fun onStart() { + super.onStart() + viewModel.bind(this) + } + + override fun render(state: State) { + searchInput.currentQuery = state.queryString + resultsView.showResults(state.results) + error_view.visibility = if (state.error != null) VISIBLE else INVISIBLE + loading_spinner.visibility = if (state.loading) VISIBLE else INVISIBLE + + + Log.v("APP_STATE", "state: $state") + } + + override fun onStop() { + viewModel.unbind() + super.onStop() + } +} + +internal class SearchViewModelFactory( + private val searchActivity: SearchActivity) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + val dependencies = searchActivity.application as Dependencies + val networkDependencyProvider = dependencies.networkDependencyProvider + val searchDependencyProvider = SearchDependencyProvider( + networkDependencyProvider, + dependencies.endpoints + ) + + return SearchViewModel(searchDependencyProvider.provideSearchStore()) + .let { modelClass.cast(it) }!! + } +} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputView.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt similarity index 79% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputView.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt index 238b2078..1899033b 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputView.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchInputView.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import android.content.Context import android.support.constraint.ConstraintLayout @@ -15,29 +15,34 @@ import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.TextView import com.novoda.movies.mvi.search.R +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action.* +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject import kotlinx.android.synthetic.main.search_bar.view.* internal class SearchInputView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), SearchInputViewable { + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { private lateinit var searchInput: EditText private lateinit var clearTextButton: View - override var currentQuery: String + private val actionStream: PublishSubject = PublishSubject.create() + + var currentQuery: String get() = searchInput.text.toString() set(text) { searchInput.fillWith(text) searchInput.setSelection(text.length) } - override var onQuerySubmitted: () -> Unit = {} - override var onQueryChanged: (query: String) -> Unit = {} - override var onQueryCleared: () -> Unit = {} + val actions: Observable + get() = actionStream - override fun showKeyboard() { + private fun showKeyboard() { searchInput.showKeyboard() } @@ -52,12 +57,11 @@ internal class SearchInputView @JvmOverloads constructor( clearTextButton.setOnClickListener { clearText() } } - private fun setupSearchInput() { searchInput.isSaveEnabled = false searchInput.setOnEditorActionListener { inputView, actionId, keyEvent -> if (actionId == EditorInfo.IME_ACTION_SEARCH || enterKeyPressed(keyEvent)) { - onQuerySubmitted() + actionStream.onNext(ExecuteSearch) inputView.hideKeyboard() inputView.clearFocus() return@setOnEditorActionListener true @@ -69,7 +73,7 @@ internal class SearchInputView @JvmOverloads constructor( } private fun clearText() { - searchInput.text.clear() + actionStream.onNext(ClearQuery) } private fun enterKeyPressed(keyEvent: KeyEvent?): Boolean { @@ -79,9 +83,9 @@ internal class SearchInputView @JvmOverloads constructor( } private val textChangedListener = object : - AfterTextChangedWatcher { + AfterTextChangedWatcher { override fun afterTextChanged(text: Editable) { - onQueryChanged(text.toString()) + actionStream.onNext(ChangeQuery(text.toString())) val showClear = text.isNotEmpty() clearTextButton.visibility = if (showClear) View.VISIBLE else View.GONE diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultCountViewHolder.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultCountViewHolder.kt similarity index 95% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultCountViewHolder.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultCountViewHolder.kt index cee22cb4..f59ce9e4 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultCountViewHolder.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultCountViewHolder.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import android.content.Context import android.support.annotation.PluralsRes diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultItemViewHolder.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultItemViewHolder.kt similarity index 98% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultItemViewHolder.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultItemViewHolder.kt index fca567bb..0a8881dc 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultItemViewHolder.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultItemViewHolder.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import android.annotation.SuppressLint import android.graphics.drawable.Drawable diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsAdapter.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsAdapter.kt similarity index 96% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsAdapter.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsAdapter.kt index 5526266f..bba5a180 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsAdapter.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsAdapter.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import android.support.v7.widget.RecyclerView import android.view.ViewGroup diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsConverter.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsConverter.kt similarity index 75% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsConverter.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsConverter.kt index 7f657bc3..0f893d3a 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsConverter.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsConverter.kt @@ -1,7 +1,7 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation -import com.novoda.movies.mvi.search.SearchResultItem -import com.novoda.movies.mvi.search.SearchResults +import com.novoda.movies.mvi.search.domain.SearchResultItem +import com.novoda.movies.mvi.search.domain.SearchResults internal open class SearchResultsConverter { diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsView.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsView.kt similarity index 89% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsView.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsView.kt index 83a4d44f..2c695537 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsView.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchResultsView.kt @@ -1,4 +1,4 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import android.content.Context import android.support.constraint.ConstraintLayout @@ -15,7 +15,7 @@ internal class SearchResultsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), SearchResultsViewable { +) : ConstraintLayout(context, attrs, defStyleAttr) { private lateinit var resultList: RecyclerView private lateinit var noResultsView: View @@ -38,16 +38,16 @@ internal class SearchResultsView @JvmOverloads constructor( resultList.layoutManager = LinearLayoutManager(context) } - override fun showResults(results: ViewSearchResults) { + fun showResults(results: ViewSearchResults) { adapter.bind(results) showAllExcept(noResultsView) } - override fun showTextInput() { + fun showTextInput() { hideAll() } - override fun showNoResults(attemptedQuery: String) { + fun showNoResults(attemptedQuery: String) { val text = context.getString(R.string.search_no_results_description, attemptedQuery) noResultsView.no_results_description.text = text hideAllExcept(noResultsView) diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt new file mode 100644 index 00000000..0e7f8b96 --- /dev/null +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/SearchViewModel.kt @@ -0,0 +1,41 @@ +package com.novoda.movies.mvi.search.presentation + +import android.arch.lifecycle.ViewModel +import com.novoda.movies.mvi.search.BaseStore +import com.novoda.movies.mvi.search.Displayer +import com.novoda.movies.mvi.search.domain.SearchReducer +import io.reactivex.disposables.Disposable + +internal typealias SearchStore = BaseStore +internal typealias SearchDisplayer = Displayer + +internal class SearchViewModel(private val store: SearchStore) : ViewModel() { + private val wireDisposable = store.wire() + private var bindDisposable: Disposable? = null + + fun bind(displayer: SearchDisplayer) { + bindDisposable = store.bind(displayer = displayer) + } + + override fun onCleared() { + super.onCleared() + + unbind() + wireDisposable.dispose() + } + + fun unbind() = bindDisposable?.dispose() + + internal data class State( + var queryString: String, + var loading: Boolean = false, + var results: ViewSearchResults, + var error: Throwable? = null + ) + + internal sealed class Action { + data class ChangeQuery(val queryString: String) : Action() + object ExecuteSearch : Action() + object ClearQuery : Action() + } +} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/ViewSearchResults.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/ViewSearchResults.kt similarity index 61% rename from ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/ViewSearchResults.kt rename to ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/ViewSearchResults.kt index 798b00ef..79b69e11 100644 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/ViewSearchResults.kt +++ b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/presentation/ViewSearchResults.kt @@ -1,11 +1,15 @@ -package com.novoda.movies.mvi.search.view +package com.novoda.movies.mvi.search.presentation import java.net.URL internal data class ViewSearchResults( val totalItemCount: Int, val items: List -) +) { + companion object { + val emptyResults = ViewSearchResults(0, emptyList()) + } +} internal data class ViewSearchItem( val id: String, diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputViewable.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputViewable.kt deleted file mode 100644 index 6fddb150..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchInputViewable.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.novoda.movies.mvi.search.view - -internal interface SearchInputViewable { - var currentQuery: String - var onQuerySubmitted: () -> Unit - var onQueryChanged: (query: String) -> Unit - - fun showKeyboard() - var onQueryCleared: () -> Unit -} diff --git a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsViewable.kt b/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsViewable.kt deleted file mode 100644 index 91b91bb3..00000000 --- a/ModelViewIntentSample/search/src/main/java/com/novoda/movies/mvi/search/view/SearchResultsViewable.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.novoda.movies.mvi.search.view - -internal interface SearchResultsViewable { - fun showResults(results: ViewSearchResults) - fun showTextInput() - fun showNoResults(attemptedQuery: String) -} diff --git a/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml b/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml index 9ae8b571..a125c63a 100644 --- a/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml +++ b/ModelViewIntentSample/search/src/main/res/layout/activity_search.xml @@ -15,16 +15,16 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> - - - + + + diff --git a/ModelViewIntentSample/search/src/main/res/values/strings.xml b/ModelViewIntentSample/search/src/main/res/values/strings.xml index 89b5d2bc..ed1d9fff 100644 --- a/ModelViewIntentSample/search/src/main/res/values/strings.xml +++ b/ModelViewIntentSample/search/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Search No Results found for \"%s\" + There was an error fetching the movies diff --git a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt deleted file mode 100644 index e0248de2..00000000 --- a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/ExampleTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.novoda.movies.mvi.search - -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - - -internal class ExampleTest { - - @Test - fun `should assert sth`() { - val foo = mock() - whenever(foo.bar()).thenReturn("bar2") - - assertThat(foo.bar()).isEqualTo("bar2") - } - - class Foo { - fun bar(): String = "bar" - } -} diff --git a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt new file mode 100644 index 00000000..c846e39e --- /dev/null +++ b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/domain/SearchMiddlewareTest.kt @@ -0,0 +1,83 @@ +package com.novoda.movies.mvi.search.domain + +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.stub +import com.novoda.movies.mvi.search.data.SearchBackend +import com.novoda.movies.mvi.search.domain.SearchReducer.Changes +import com.novoda.movies.mvi.search.presentation.SearchViewModel.Action +import com.novoda.movies.mvi.search.presentation.SearchViewModel.State +import com.novoda.movies.mvi.search.presentation.ViewSearchResults +import io.reactivex.Single +import io.reactivex.observers.TestObserver +import io.reactivex.schedulers.Schedulers +import io.reactivex.subjects.PublishSubject +import org.junit.Before +import org.junit.Test + +class SearchMiddlewareTest { + + private val backend: SearchBackend = mock() + private val searchMiddleware = SearchMiddleware(backend, Schedulers.trampoline()) + + private val actions = PublishSubject.create() + private val state = PublishSubject.create() + private lateinit var changes: TestObserver + + @Before + fun setUp() { + changes = searchMiddleware.bind(actions, state).test() + } + + @Test + fun `GIVEN state with query WHEN query changed THEN query is updated`() { + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) + + actions.onNext(Action.ChangeQuery(queryString = "superman")) + + changes.assertValue(Changes.UpdateSearchQuery("superman")) + } + + @Test + fun `WHEN query cleared THEN updated query is empty AND results are removed`() { + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) + + actions.onNext(Action.ClearQuery) + + changes.assertValues( + Changes.UpdateSearchQuery(""), + Changes.RemoveResults + ) + } + + @Test + fun `GIVEN dataSource has results WHEN execute search THEN search is in progress AND search is completed`() { + val searchResults = SearchResults(items = listOf()) + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) + backend.stub { on { search("iron man") } doReturn Single.just(searchResults) } + + actions.onNext(Action.ExecuteSearch) + + changes.assertValues( + Changes.ShowProgress, + Changes.AddResults(results = searchResults), + Changes.HideProgress + ) + } + + @Test + fun `GIVEN dataSource errors WHEN execute search THEN search is in progress AND search failed`() { + val exception = Throwable() + state.onNext(State(queryString = "iron man", results = ViewSearchResults.emptyResults)) + backend.stub { on { search("iron man") } doReturn (Single.error(exception)) } + + actions.onNext(Action.ExecuteSearch) + + changes.assertValues( + Changes.ShowProgress, + Changes.HandleError(exception), + Changes.HideProgress + ) + } + +} diff --git a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt deleted file mode 100644 index 8aa0cdcb..00000000 --- a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/RealSearchResultsModelTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.novoda.movies.mvi.search.results - -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever -import com.novoda.movies.mvi.search.RealSearchResultsModel -import com.novoda.movies.mvi.search.SearchResults -import com.novoda.movies.mvi.search.SearchResultsModel.State.* -import com.novoda.movies.mvi.search.api.SearchBackend -import io.reactivex.Single -import org.junit.Test - -class RealSearchResultsModelTest { - - private val queryString = "gateau" - private val backend: SearchBackend = mock() - - - @Test - fun `should emit initial state without initial query when initial query empty`() { - model() - .state() - .test() - .assertValue(Initial) - } - - @Test - fun `should show text input when user changes query`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - val state = model.state().test() - - model.queryChanged(queryString) - - state.assertValues( - Initial, - TextInput(queryString) - ) - } - - @Test - fun `should start with loading`() { - givenBackendReturn(Single.never()) - val model = model() - val state = model.state() - - model.queryChanged(queryString) - model.executeSearch() - - state.test() - .assertValue(Loading(queryString)) - } - - @Test - fun `should emit result after loading`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - val state = model.state().test() - - model.queryChanged(queryString) - model.executeSearch() - - state.assertValues( - Initial, - TextInput(queryString), - Loading(queryString), - Content(queryString, searchResults) - ) - } - - @Test - fun `should emit latest state`() { - val searchResults = results() - givenBackendSucceedWith(searchResults) - val model = model() - - model.queryChanged(queryString) - model.executeSearch() - - model.state() - .test() - .assertValues(Content(queryString, searchResults)) - } - - @Test - fun `should emit error when search failed`() { - val exception = Exception() - givenBackendFailWith(exception) - val model = model() - - model.queryChanged(queryString) - model.executeSearch() - - model.state() - .test() - .assertValue(Error(queryString, exception)) - } - - @Test - fun `should recover from error`() { - val exception = Exception() - givenBackendFailWith(exception) - val model = model() - val state = model.state().test() - model.queryChanged(queryString) - model.executeSearch() - - val searchResults = results() - givenBackendSucceedWith(searchResults) - model.executeSearch() - - state.assertValues( - Initial, - TextInput(queryString), - Loading(queryString), - Error(queryString, exception), - Loading(queryString), - Content(queryString, searchResults) - ) - } - - private fun model(): RealSearchResultsModel { - return RealSearchResultsModel( - backend, - SynchronousSchedulingStrategy() - ) - } - - private fun results(): SearchResults = SearchResults( - listOf(mock(), mock()), - totalResults = 10 - ) - - private fun givenBackendSucceedWith(searchResults: SearchResults) { - givenBackendReturn(Single.just(searchResults)) - } - - private fun givenBackendReturn(single: Single) { - whenever(backend.search(queryString)).thenReturn(single) - } - - private fun givenBackendFailWith(exception: Exception) { - givenBackendReturn(Single.error(exception)) - } -} diff --git a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt b/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt deleted file mode 100644 index 484b0691..00000000 --- a/ModelViewIntentSample/search/src/test/java/com/novoda/movies/mvi/search/results/SynchronousSchedulingStrategy.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.novoda.movies.mvi.search.results - -import com.novoda.movies.mvi.search.SchedulingStrategy -import io.reactivex.Scheduler -import io.reactivex.schedulers.Schedulers - -class SynchronousSchedulingStrategy : SchedulingStrategy { - override val work: Scheduler = Schedulers.trampoline() - override val ui: Scheduler = Schedulers.trampoline() -} diff --git a/ModelViewIntentSample/squence_diagram.png b/ModelViewIntentSample/squence_diagram.png new file mode 100644 index 00000000..3d884fd8 Binary files /dev/null and b/ModelViewIntentSample/squence_diagram.png differ