Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVI ViewModel #59

Merged
merged 17 commits into from
Sep 10, 2019
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.PublishSubject

class BaseStore<A, S, C>(
private val schedulingStrategy: SchedulingStrategy,
private val reducer: Reducer<S, C>,
private val middlewares: List<Middleware<A, S, C>>,
private val initialValue: S
private val schedulingStrategy: SchedulingStrategy,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do I need to have a consistent indenting with the rest of the project?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you import the novoda code styles from https://github.com/novoda/novoda/tree/master/ide-settings/intellij ?

private val reducer: Reducer<S, C>,
private val middlewares: List<Middleware<A, S, C>>,
private val initialValue: S
) : Store<A, S, C> {
private val changes = PublishSubject.create<C>()
private val state = BehaviorSubject.createDefault(initialValue)
Expand All @@ -19,38 +19,38 @@ class BaseStore<A, S, C>(
val disposables = CompositeDisposable()

val newState = changes.scan(
initialValue, { state, change ->
reducer.reduce(state, change)
}
initialValue, { state, change ->
reducer.reduce(state, change)
}
)

disposables.add(
newState
.subscribeOn(schedulingStrategy.work)
.subscribe(state::onNext)
newState
.subscribeOn(schedulingStrategy.work)
.subscribe(state::onNext)
)

for (middleware in middlewares) {
val observable = middleware
.bind(actions, state)
.subscribeOn(schedulingStrategy.work)
.bind(actions, state)
.subscribeOn(schedulingStrategy.work)
disposables.add(observable.subscribe(changes::onNext))
}

return disposables
}

override fun bind(view: MVIView<A, S>): Disposable {
override fun bind(displayer: Displayer<A, S>): Disposable {
val disposables = CompositeDisposable()

disposables.add(view.actions.subscribe(actions::onNext))
disposables.add(displayer.actions.subscribe(actions::onNext))

disposables.add(
state
.observeOn(schedulingStrategy.ui)
.subscribe(view::render)
state
.observeOn(schedulingStrategy.ui)
.subscribe(displayer::render)
)

return disposables
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ package com.novoda.movies.mvi.search
import io.reactivex.Observable
import io.reactivex.disposables.Disposable

// TODO: Consider a better interface for the view so that we can compose
// more views in the same Activity
interface MVIView<A, S> {
interface Displayer<A, S> {
val actions: Observable<A>

fun render(state: S)
}

Expand All @@ -21,5 +18,5 @@ interface Middleware<A, S, C> {

interface Store<A, S, C> {
fun wire(): Disposable
fun bind(view: MVIView<A, S>): Disposable
fun bind(displayer: Displayer<A, S>): Disposable
}
1 change: 1 addition & 0 deletions ModelViewIntentSample/search/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
3 changes: 1 addition & 2 deletions ModelViewIntentSample/search/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".presentation.SearchActivity"
android:screenOrientation="portrait"/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

<activity android:name=".presentation.SearchActivity" />
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ internal class ApiSearchResultsConverter(
return apiSearchResults.toSearchResults()
}


private fun ApiSearchResults.toSearchResults(): SearchResults {
return SearchResults(
items = results.map {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package com.novoda.movies.mvi.search.data
import com.novoda.movies.mvi.search.domain.SearchResults
import io.reactivex.Single

interface MovieDataSource {
tobiasheine marked this conversation as resolved.
Show resolved Hide resolved
fun search(query: String): Single<SearchResults>
}

internal class SearchBackend(
private val searchApi: SearchApi,
private val searchConverter: ApiSearchResultsConverter
) {
): MovieDataSource {

fun search(query: String): Single<SearchResults> {
override fun search(query: String): Single<SearchResults> {
return searchApi
.search(query)
.map(searchConverter::convert)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,33 @@ 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.MovieDataSource
import com.novoda.movies.mvi.search.data.SearchApi
import com.novoda.movies.mvi.search.data.SearchBackend
import com.novoda.movies.mvi.search.presentation.SearchActivity.State
import com.novoda.movies.mvi.search.presentation.SearchResultsConverter
import com.novoda.movies.mvi.search.presentation.SearchStore
import com.novoda.movies.mvi.search.presentation.ViewSearchResults

internal class SearchDependencyProvider(
private val networkDependencyProvider: NetworkDependencyProvider,
private val endpoints: Endpoints
) {

private fun provideSearchBackend(): SearchBackend {
private fun provideMovieDataSource(): MovieDataSource {
val searchApi = networkDependencyProvider.provideRetrofit().create(SearchApi::class.java)
return SearchBackend(
searchApi,
ApiSearchResultsConverter(endpoints)
)
}

fun provideSearchStore(): BaseStore<SearchAction, ScreenState, ScreenStateChanges> {
fun provideSearchStore(): SearchStore {
return BaseStore(
reducer = SearchReducer(provideSearchResultsConverter()),
schedulingStrategy = ProductionSchedulingStrategy(),
middlewares = listOf(SearchMiddleware(provideSearchBackend(), ProductionSchedulingStrategy().work)),
initialValue = ScreenState(queryString = "", results = ViewSearchResults.emptyResults)
reducer = SearchReducer(provideSearchResultsConverter()),
schedulingStrategy = ProductionSchedulingStrategy(),
middlewares = listOf(SearchMiddleware(provideMovieDataSource(), ProductionSchedulingStrategy().work)),
initialValue = State(queryString = "", results = ViewSearchResults.emptyResults)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,48 @@
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.ScreenStateChanges.*
import com.novoda.movies.mvi.search.data.MovieDataSource
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.SearchActivity.Action
import com.novoda.movies.mvi.search.presentation.SearchActivity.Action.*
import com.novoda.movies.mvi.search.presentation.SearchActivity.State
import io.reactivex.Observable
import io.reactivex.Scheduler
import io.reactivex.functions.BiFunction


internal class SearchMiddleware(
private val backend: SearchBackend,
private val dataSource: MovieDataSource,
private val workScheduler: Scheduler
) : Middleware<SearchAction, ScreenState, ScreenStateChanges> {
) : Middleware<Action, State, Changes> {

override fun bind(actions: Observable<SearchAction>, state: Observable<ScreenState>): Observable<ScreenStateChanges> {
override fun bind(actions: Observable<Action>, state: Observable<State>): Observable<Changes> {
return actions
.withLatestFrom(state, actionToState())
.switchMap { (action, state) -> handle(action, state) }
}

private fun actionToState(): BiFunction<SearchAction, ScreenState, Pair<SearchAction, ScreenState>> =
private fun actionToState(): BiFunction<Action, State, Pair<Action, State>> =
BiFunction { action, state -> action to state }

private fun handle(action: SearchAction, state: ScreenState): Observable<ScreenStateChanges> =
private fun handle(action: Action, state: State): Observable<Changes> =
when (action) {
is SearchAction.ChangeQuery -> Observable.just(UpdateSearchQuery(action.queryString))
is SearchAction.ExecuteSearch -> processAction(state)
is SearchAction.ClearQuery -> processClearQuery()
is ChangeQuery -> Observable.just(UpdateSearchQuery(action.queryString))
is ExecuteSearch -> processAction(state)
is ClearQuery -> processClearQuery()
}

private fun processClearQuery(): Observable<ScreenStateChanges> {
val updateQuery = Observable.just(UpdateSearchQuery("") as ScreenStateChanges)
private fun processClearQuery(): Observable<Changes> {
val updateQuery = Observable.just(UpdateSearchQuery("") as Changes)
val removeResults = Observable.just(RemoveResults)
return updateQuery.concatWith(removeResults)
}

private fun processAction(state: ScreenState): Observable<ScreenStateChanges> {
val loadContent = backend.search(state.queryString)
private fun processAction(state: State): Observable<Changes> {
val loadContent = dataSource.search(state.queryString)
.toObservable()
.map { searchResult -> AddResults(searchResult) as ScreenStateChanges }
.map { searchResult -> AddResults(searchResult) as Changes }
.startWith(ShowProgress)
.onErrorReturn { throwable -> HandleError(throwable) }
val hideProgress = Observable.just(HideProgress)
Expand All @@ -49,10 +53,3 @@ internal class SearchMiddleware(
}
}

internal sealed class SearchAction {
data class ChangeQuery(val queryString: String) : SearchAction()
object ExecuteSearch : SearchAction()
object ClearQuery : SearchAction()
}


Original file line number Diff line number Diff line change
@@ -1,61 +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.SearchActivity.State
import com.novoda.movies.mvi.search.presentation.SearchResultsConverter
import com.novoda.movies.mvi.search.presentation.ViewSearchResults

internal class SearchReducer(
private val searchResultsConverter: SearchResultsConverter
) : Reducer<ScreenState, ScreenStateChanges> {

override fun reduce(state: ScreenState, change: ScreenStateChanges): ScreenState =
when (change) {
is ScreenStateChanges.ShowProgress -> state.showLoading()
is ScreenStateChanges.HideProgress -> state.hideLoading()
is ScreenStateChanges.AddResults -> state.addResults(change.results)
is ScreenStateChanges.RemoveResults -> state.removeResults()
is ScreenStateChanges.UpdateSearchQuery -> state.updateQuery(change.queryString)
is ScreenStateChanges.HandleError -> state.toError(change.throwable)
}

private fun ScreenState.addResults(results: SearchResults): ScreenState {
private val searchResultsConverter: SearchResultsConverter
) : Reducer<State, SearchReducer.Changes> {

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 ScreenState.removeResults(): ScreenState {
private fun State.removeResults(): State {
return copy(results = ViewSearchResults.emptyResults)
}

private fun ScreenState.toError(throwable: Throwable): ScreenState {
private fun State.toError(throwable: Throwable): State {
return copy(error = throwable)
}

private fun ScreenState.updateQuery(queryString: String): ScreenState {
private fun State.updateQuery(queryString: String): State {
return copy(queryString = queryString)
}

private fun ScreenState.hideLoading(): ScreenState {
private fun State.hideLoading(): State {
return copy(loading = false)
}

private fun ScreenState.showLoading(): ScreenState {
private fun State.showLoading(): State {
return copy(loading = true)
}

internal data class ScreenState(
var queryString: String,
var loading: Boolean = false,
var results: ViewSearchResults,
var error: Throwable? = null
)

sealed class ScreenStateChanges {

object ShowProgress : ScreenStateChanges()
object HideProgress : ScreenStateChanges()
data class AddResults(val results: SearchResults) : ScreenStateChanges()
object RemoveResults: ScreenStateChanges()
data class HandleError(val throwable: Throwable) : ScreenStateChanges()
data class UpdateSearchQuery(val queryString: String) : ScreenStateChanges()
}