diff --git a/api/build.gradle b/api/build.gradle index a80757d..735868b 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -7,19 +7,19 @@ targetCompatibility = JavaVersion.VERSION_1_8 dependencies { implementation Deps.kotlin_jdk implementation Deps.retrofit - compile Deps.retrofitMoshiConverter + api Deps.retrofitMoshiConverter implementation Deps.okhttp - compile Deps.loggingInterceptor - compile project(':mocks') + api Deps.loggingInterceptor + implementation project(':mocks') compile(TestDeps.mockWebServer) { exclude group: 'org.hamcrest', module: 'hamcrest-core' } implementation Deps.retrofitRxJavaAdapter - compile Deps.dagger - compile Deps.daggerAndroid - compile Deps.daggerAndroidSupport + api Deps.dagger + api Deps.daggerAndroid + api Deps.daggerAndroidSupport kapt Deps.daggerCompiler kapt Deps.daggerAndroidProcessor diff --git a/api/src/main/java/com/ragdroid/api/MarvelApi.kt b/api/src/main/java/com/ragdroid/api/MarvelApi.kt index 5fecf67..0ad2393 100644 --- a/api/src/main/java/com/ragdroid/api/MarvelApi.kt +++ b/api/src/main/java/com/ragdroid/api/MarvelApi.kt @@ -23,17 +23,30 @@ const val CHARACTER_ID = "characterId" interface MarvelApi { @GET("characters") - fun getCharacters(@Query(API_KEY) publicKey: String, - @Query(HASH) md5Digest: String, - @Query(TIMESTAMP) timestamp: Long, - @Query(OFFSET) offset: Int?, - @Query(LIMIT) limit: Int?): Single>> + suspend fun getCharacters(@Query(API_KEY) publicKey: String, + @Query(HASH) md5Digest: String, + @Query(TIMESTAMP) timestamp: Long, + @Query(OFFSET) offset: Int?, + @Query(LIMIT) limit: Int?): TDataWrapper> @GET("characters/{characterId}") - fun getCharacter(@Path(CHARACTER_ID) characterId: Long, - @Query(API_KEY) publicKey: String, - @Query(HASH) md5Digest: String, - @Query(TIMESTAMP) timestamp: Long): Single>> + suspend fun getCharacter(@Path(CHARACTER_ID) characterId: Long, + @Query(API_KEY) publicKey: String, + @Query(HASH) md5Digest: String, + @Query(TIMESTAMP) timestamp: Long): TDataWrapper> + + @GET("characters") + fun getCharactersSingle(@Query(API_KEY) publicKey: String, + @Query(HASH) md5Digest: String, + @Query(TIMESTAMP) timestamp: Long, + @Query(OFFSET) offset: Int?, + @Query(LIMIT) limit: Int?): Single>> + + @GET("characters/{characterId}") + fun getCharacterSingle(@Path(CHARACTER_ID) characterId: Long, + @Query(API_KEY) publicKey: String, + @Query(HASH) md5Digest: String, + @Query(TIMESTAMP) timestamp: Long): Single>> /** * Retrieve list of comics by character Id diff --git a/build.gradle b/build.gradle index 6a96e91..280474c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,16 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.3.21' repositories { mavenCentral() google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.4.0-rc01' + classpath 'com.android.tools.build:gradle:3.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin_version}" classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin_version}" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 9ac32cb..ec8cb0f 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -1,22 +1,23 @@ object Versions { - val kotlin_version = "1.3.11" + val kotlin_version = "1.3.30" + val kotlinx_coroutines = "1.3.2" val compileSdk = 28 val minSdk = 21 - val retrofitVersion = "2.3.0" + val retrofitVersion = "2.6.0" val support_library = "27.1.1" val material = "1.0.0" val okhttpVersion = "3.9.1" val constraint_layout = "2.0.0-alpha3" val navigationVersion = "1.0.0-rc01" - val lifecycleEx = "2.0.0" - val lifecycleRx = "2.0.0" + val lifecycleEx = "2.2.0-alpha02" + val lifecycleRx = "2.2.0-alpha02" val lifecycleSavedState = "1.0.0-alpha02" val lifecycleCompiler = "2.0.0" val arch_comp = "1.1.1" val arch_comp_paging= "2.1.0" - val dagger = "2.20" + val dagger = "2.24" val ktlint = "0.10.0" val leakcanary = "1.6.2" val rx = "2.1.6" @@ -29,12 +30,16 @@ object Versions { val reclaim = "1.2.2" val rxBindings = "2.1.1" val appCompat = "1.0.2" + val android_ktx = "1.2.0-alpha02" + val coroutine_binding = "1.0.0-RC2" + val flowExtensions = "0.0.2" + val wasabeef = "3.0.0" // test libraries val archTesting = "2.0.1" val kluent = "1.4" val spek = "1.1.5" - val mockitoKotlin = "1.5.0" + val mockitoKotlin = "2.1.0" val mockito = "2.8.9" val testRunner = "1.1.1" val espresso = "3.1.0" @@ -42,7 +47,10 @@ object Versions { } object Deps { - val kotlin_jdk = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin_version}" + val kotlin_jdk = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${Versions.kotlin_version}" + val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.kotlinx_coroutines}" + val coroutinesRxInterop = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:${Versions.kotlinx_coroutines}" + val androidKtx = "androidx.core:core-ktx:${Versions.android_ktx}" val constraintLayout = "androidx.constraintlayout:constraintlayout:${Versions.constraint_layout}" val timber = "com.jakewharton.timber:timber:${Versions.timberVersion}" val reclaim = "com.github.fueled:reclaim:${Versions.reclaim}" @@ -50,6 +58,8 @@ object Deps { val lifecycleRx = "androidx.lifecycle:lifecycle-reactivestreams:${Versions.lifecycleRx}" val lifecycleSavedState = "androidx.lifecycle:lifecycle-viewmodel-savedstate:${Versions.lifecycleSavedState}" val lifecycleCompiler = "androidx.lifecycle:lifecycle-compiler:${Versions.lifecycleRx}" + val lifecycleViewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycleEx}" + val lifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.lifecycleEx}" val navigationFragment = "android.arch.navigation:navigation-fragment-ktx:${Versions.navigationVersion}" val navigationUIKtx = "android.arch.navigation:navigation-ui-ktx:${Versions.navigationVersion}" val pagingRuntime = "androidx.paging:paging-runtime:${Versions.arch_comp_paging}" @@ -72,6 +82,9 @@ object Deps { val retrofitRxJavaAdapter = "com.squareup.retrofit2:adapter-rxjava2:${Versions.retrofitVersion}" val okhttp = "com.squareup.okhttp3:okhttp:${Versions.okhttpVersion}" val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.okhttpVersion}" + val coroutineBindingSwipeRefresh = "com.github.satoshun.coroutinebinding:coroutinebinding-swiperefreshlayout:${Versions.coroutine_binding}" + val flowExtensions = "com.github.akarnokd:kotlin-flow-extensions:${Versions.flowExtensions}" + val recyclerViewAnimations = "jp.wasabeef:recyclerview-animators:${Versions.wasabeef}" } object TestDeps { @@ -87,6 +100,7 @@ object TestDeps { val kluent = "org.amshove.kluent:kluent:${Versions.kluent}" val spek = "org.jetbrains.spek:spek-api:${Versions.spek}" val spekJunitPlatformEngine = "org.jetbrains.spek:spek-junit-platform-engine:${Versions.spek}" - val mockitoKotlin = "com.nhaarman:mockito-kotlin-kt1.1:${Versions.mockitoKotlin}" + val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:${Versions.mockitoKotlin}" val mockito = "org.mockito:mockito-core:${Versions.mockito}" + val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.kotlinx_coroutines}" } \ No newline at end of file diff --git a/data/build.gradle b/data/build.gradle index 9e13466..d11b79c 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -15,22 +15,24 @@ sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 dependencies { + implementation Deps.coroutinesAndroid + implementation Deps.coroutinesRxInterop implementation Deps.kotlin_jdk - compile project(':api') + api project(':api') - compile Deps.timber + implementation Deps.timber implementation Deps.rx - compile Deps.dagger - compile Deps.daggerAndroid - compile Deps.daggerAndroidSupport + implementation Deps.dagger + implementation Deps.daggerAndroid + implementation Deps.daggerAndroidSupport kapt Deps.daggerCompiler kapt Deps.daggerAndroidProcessor - testCompile TestDeps.kluent + testImplementation TestDeps.kluent testImplementation TestDeps.spek testImplementation TestDeps.spekJunitPlatformEngine - testCompile TestDeps.mockitoKotlin + testImplementation TestDeps.mockitoKotlin testImplementation TestDeps.mockito } diff --git a/data/src/main/java/com/ragdroid/data/MainRepositoryImpl.kt b/data/src/main/java/com/ragdroid/data/MainRepositoryImpl.kt index 246d994..c23cd59 100644 --- a/data/src/main/java/com/ragdroid/data/MainRepositoryImpl.kt +++ b/data/src/main/java/com/ragdroid/data/MainRepositoryImpl.kt @@ -8,21 +8,36 @@ import com.ragdroid.data.entity.AppConfig import com.ragdroid.data.entity.CharacterMapper import com.ragdroid.data.entity.CharacterMarvel import io.reactivex.Single +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import javax.inject.Inject /** * Created by garimajain on 18/11/17. */ -class MainRepositoryImpl -@Inject -constructor( +class MainRepositoryImpl @Inject constructor( private val marvelApi: MarvelApi, private val characterMapper: CharacterMapper, private val config: AppConfig, private val helpers: Helpers): MainRepository { + override suspend fun fetchCharacters(): List { + val timeStamp = System.currentTimeMillis() + val charactersWrapper = marvelApi.getCharacters( + config.publicKey, + helpers.buildMD5Digest("" + timeStamp + config.privateKey + + config.publicKey), + timeStamp, + 0, + 50) + val characters = charactersWrapper.data.results + .map { + characterMapper.map(it) + }.toList() + return characters + } - override fun fetchCharacters(): Single> { + override fun fetchCharactersSingle(): Single> { val timeStamp = System.currentTimeMillis() return charactersApiSingle(timeStamp) .map { dataWrapper: TDataWrapper> -> @@ -33,7 +48,7 @@ constructor( } } - override fun fetchCharacter(id: Long): Single { + override fun fetchCharacterSingle(id: Long): Single { val timeStamp = System.currentTimeMillis() return characterApiSingle(id, timeStamp) .map { dataWrapper: TDataWrapper> -> @@ -47,7 +62,7 @@ constructor( } private fun characterApiSingle(id: Long, timeStamp: Long): Single>> { - return marvelApi.getCharacter( + return marvelApi.getCharacterSingle( id, config.publicKey, helpers.buildMD5Digest("" + timeStamp + config.privateKey @@ -55,8 +70,9 @@ constructor( timeStamp ) } + private fun charactersApiSingle(timeStamp: Long): Single>> { - return marvelApi.getCharacters( + return marvelApi.getCharactersSingle( config.publicKey, helpers.buildMD5Digest("" + timeStamp + config.privateKey + config.publicKey), @@ -66,10 +82,31 @@ constructor( ) } + override suspend fun fetchCharacter(id: Long): CharacterMarvel { + val timeStamp = System.currentTimeMillis() + val characterWrapper = marvelApi.getCharacter( + id, + config.publicKey, + helpers.buildMD5Digest("" + timeStamp + config.privateKey + + config.publicKey), timeStamp) + + val results = characterWrapper.data.results + .map { + characterMapper.map(it) + }.getOrNull(0) ?: throw IllegalAccessException("Character for id $id not found") + return results + } + + + } interface MainRepository { - fun fetchCharacters(): Single> - fun fetchCharacter(id: Long): Single + fun fetchCharactersSingle(): Single> + fun fetchCharacterSingle(id: Long): Single + + + suspend fun fetchCharacters(): List + suspend fun fetchCharacter(id: Long): CharacterMarvel } diff --git a/data/src/test/java/com/ragdroid/data/MainRepositorySpec.kt b/data/src/test/java/com/ragdroid/data/MainRepositorySpec.kt index 6b037be..1f010af 100644 --- a/data/src/test/java/com/ragdroid/data/MainRepositorySpec.kt +++ b/data/src/test/java/com/ragdroid/data/MainRepositorySpec.kt @@ -41,10 +41,10 @@ object MainRepositorySpec: Spek({ } on("fetching from repository") { - `when`(mockApi.getCharacters(anyString(), anyString(), anyLong(), anyInt(), anyInt())) + `when`(mockApi.getCharactersSingle(anyString(), anyString(), anyLong(), anyInt(), anyInt())) .thenReturn(Single.just(getFakeMarvelCharacters())) val testObserver = TestObserver>() - repository.fetchCharacters() + repository.fetchCharactersSingle() .subscribe(testObserver) it("should receive 1 value") { @@ -61,10 +61,10 @@ object MainRepositorySpec: Spek({ on("fetching from repository gives error") { val error = mock(Throwable::class) - `when`(mockApi.getCharacters(anyString(), anyString(), anyLong(), anyInt(), anyInt())) + `when`(mockApi.getCharactersSingle(anyString(), anyString(), anyLong(), anyInt(), anyInt())) .thenReturn(Single.error(error)) val testObserver = TestObserver>() - repository.fetchCharacters() + repository.fetchCharactersSingle() .subscribe(testObserver) it("should error") { testObserver.assertError(error) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cc7f20e..75ec518 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri Nov 10 18:33:46 IST 2017 +#Thu Oct 24 04:38:58 IST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviView.kt b/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviView.kt index 2d04f60..b982522 100644 --- a/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviView.kt +++ b/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviView.kt @@ -58,9 +58,6 @@ interface MviView { fun onMviViewCreated(savedInstanceState: Bundle?) { subscribeToViewState() subscribeToNavigationState() - if (savedInstanceState == null) { - viewModel.processActions(provideActions()) - } } /** diff --git a/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviViewModel.kt b/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviViewModel.kt index 12714e1..4923b91 100644 --- a/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviViewModel.kt +++ b/mvvmi/src/main/java/com/ragdroid/mvvmi/core/MviViewModel.kt @@ -20,7 +20,7 @@ import timber.log.Timber abstract class MviViewModel(initialState: State) : ViewModel() { private val stateProcessor: PublishProcessor = PublishProcessor.create() - protected val actionsProcessor: PublishProcessor = PublishProcessor.create() + private val actionsProcessor: PublishProcessor = PublishProcessor.create() protected var currentState: State = initialState private set @@ -55,12 +55,9 @@ abstract class MviViewModel): Flowable @CallSuper - fun processActions(actions: Flowable) { - actionsProcessor.mergeWith(actions) - .doOnNext { - Timber.v("onAction $it") - } - .compose(actionToResultTransformer) + fun processActions() { + + actionsToResultTransformer(actionsProcessor) .doOnNext { Timber.v("onResult $it") } diff --git a/mvvmi/src/main/res/values/strings.xml b/mvvmi/src/main/res/values/strings.xml index 77aa064..8542005 100644 --- a/mvvmi/src/main/res/values/strings.xml +++ b/mvvmi/src/main/res/values/strings.xml @@ -1,3 +1,2 @@ - mvvmi diff --git a/presentation/build.gradle b/presentation/build.gradle index e4640ce..d940c53 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -5,6 +5,11 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' +androidExtensions { + experimental = true +} + + android { compileSdkVersion Versions.compileSdk defaultConfig { @@ -33,13 +38,31 @@ android { dataBinding { enabled true } + sourceSets { + androidTest.java.srcDirs += 'src/androidTest/kotlin' + debug.java.srcDirs += 'src/debug/kotlin' + main.java.srcDirs += 'src/main/kotlin' + release.java.srcDirs += 'src/release/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + packagingOptions { + exclude("META-INF/*.kotlin_module") + } } dependencies { api project(':mvvmi') api project(':data') + implementation Deps.kotlin_jdk + implementation Deps.coroutinesAndroid + implementation Deps.coroutinesRxInterop + implementation Deps.androidKtx implementation Deps.constraintLayout @@ -74,6 +97,10 @@ dependencies { implementation Deps.rxBindingKt implementation Deps.rxBindingRvKt implementation Deps.rxBindingSupportV4 + implementation Deps.recyclerViewAnimations + + implementation Deps.lifecycleViewModelKtx + implementation Deps.lifecycleRuntimeKtx testImplementation TestDeps.junit testImplementation TestDeps.mockitoKotlin @@ -88,6 +115,7 @@ dependencies { debugImplementation TestDeps.leakCanary releaseImplementation TestDeps.leakCanaryNoOp + testImplementation TestDeps.coroutinesTest implementation(TestDeps.crashlytics) { transitive = true diff --git a/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersFragment.kt b/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersFragment.kt new file mode 100644 index 0000000..1cbe3e6 --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersFragment.kt @@ -0,0 +1,111 @@ +package com.ragdroid.mvi.characters + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.fueled.reclaim.ItemPresenterProvider +import com.fueled.reclaim.ItemsViewAdapter +import com.google.android.material.snackbar.Snackbar +import com.ragdroid.mvi.R +import com.ragdroid.mvi.databinding.FragmentMainBinding +import com.ragdroid.mvi.helpers.BindFragment +import com.ragdroid.mvi.helpers.SpaceItemDecoration +import com.ragdroid.mvi.items.CharacterItem +import com.ragdroid.mvi.main.MainAction +import com.ragdroid.mvi.main.MainNavigation +import com.ragdroid.mvi.main.MainViewState +import com.ragdroid.mvi.models.CharacterItemPresenter +import com.ragdroid.mvvmi.core.NavigationState +import dagger.android.support.DaggerFragment +import timber.log.Timber +import javax.inject.Inject +import jp.wasabeef.recyclerview.animators.FadeInAnimator + +/** + * A placeholder fragment containing a simple view. + */ +class CharactersFragment : DaggerFragment(), + ItemPresenterProvider, + CharacterItemPresenter { + + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + + //delegate the binding initialization to BindFragment delegate + private val binding: FragmentMainBinding by BindFragment(R.layout.fragment_main) + private val adapter: ItemsViewAdapter by lazy(LazyThreadSafetyMode.NONE) { + ItemsViewAdapter(context) + } + + + lateinit var viewModel: CharactersViewModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbar.setTitle(R.string.title_flow) + val manager = LinearLayoutManager(context) + val decoration = SpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.keyline_1), false, false, true, false) + manager.orientation = RecyclerView.VERTICAL + binding.listView.layoutManager = manager + binding.listView.adapter = adapter + binding.listView.itemAnimator = FadeInAnimator() + binding.listView.addItemDecoration(decoration) + setupViewModel(savedInstanceState) + } + + private fun setupViewModel(savedInstanceState: Bundle?) { + viewModel = ViewModelProviders.of(this, viewModelFactory).get(CharactersViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { render(it) }) + viewModel.navigationLiveData.observe(viewLifecycleOwner, Observer { navigate(it) }) + if (savedInstanceState == null) { + viewModel.processActions() + viewModel.onAction(MainAction.LoadData) + } + binding.refreshLayout.setOnRefreshListener { + viewModel.onAction(MainAction.PullToRefresh) + } + } + + + + + override fun getItemPresenter(): CharacterItemPresenter { + return this + } + + override fun onCharacterDescriptionClicked(itemId: Long) { + viewModel.onAction(MainAction.LoadDescription(itemId)) + } + + fun render(state: MainViewState) { + Timber.d("got state $state") + binding.refreshing = state.loadingState == MainViewState.LoadingState.PullToRefreshing + binding.loading = state.loadingState == MainViewState.LoadingState.Loading + val characterModelList = + state.characters.map { + CharacterItem(it, this) + } + adapter.replaceItems(characterModelList, true) + } + + + fun navigate(navigationState: NavigationState) { + when (navigationState) { + is MainNavigation.Snackbar -> Snackbar.make(binding.root, navigationState.message, Snackbar.LENGTH_SHORT).show() + else -> {//do nothing + } + } + } + +} diff --git a/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersViewModel.kt b/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersViewModel.kt new file mode 100644 index 0000000..1178da5 --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/characters/CharactersViewModel.kt @@ -0,0 +1,175 @@ +package com.ragdroid.mvi.characters + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ragdroid.data.MainRepository +import com.ragdroid.data.entity.CharacterMarvel +import com.ragdroid.mvi.base.ResourceProvider +import com.ragdroid.mvi.helpers.DispatchProvider +import com.ragdroid.mvi.helpers.merge +import com.ragdroid.mvi.helpers.ofType +import com.ragdroid.mvi.main.MainAction +import com.ragdroid.mvi.main.MainNavigation +import com.ragdroid.mvi.main.MainResult +import com.ragdroid.mvi.main.MainViewState +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.* +import timber.log.Timber +import java.lang.Exception +import javax.inject.Inject + +@ExperimentalCoroutinesApi +class CharactersViewModel @Inject constructor( + private val mainRepository: MainRepository, + private val resourceProvider: ResourceProvider, + private val dispatchProvider: DispatchProvider +): ViewModel() { + + fun onAction(action: MainAction) = broadcastChannel.offer(action) + + //we can also use kotlin-flow-extensions `PublishSubject` here + private var broadcastChannel = ConflatedBroadcastChannel() + var actionsFlow = broadcastChannel.asFlow() + + val stateLiveData: LiveData + get() = _stateLiveData + + private val _stateLiveData = MutableLiveData() + + val navigationLiveData: LiveData + get() = _navigationLiveData + private val _navigationLiveData = MutableLiveData() + + fun navigate(navigationState: MainNavigation) = _navigationLiveData.postValue(navigationState) + + fun processActions() { + viewModelScope.launch { + + actionToResultTransformer(actionsFlow) +// actionsFlow +// //to demonstrate non-streams solution +// .flatMapMerge { +// actionToResultFlow(it) +// } + .onEach { + Timber.v("onResult $it") + } + .scan(MainViewState.init()) { state, result: MainResult -> reduce(state, result) } + .onStart { Timber.d("subscribed to states") } + .flowOn(dispatchProvider.computation()) + .collect { + Timber.v("onState $it") + _stateLiveData.postValue(it) + } + } + } + + private fun actionToResultFlow(action: MainAction): Flow = flow { + when(action) { + is MainAction.PullToRefresh -> { + try { + emit(MainResult.PullToRefreshing) + val characters = mainRepository.fetchCharacters() + emit(MainResult.PullToRefreshComplete(characters)) + } catch (exception: Exception) { + Timber.e(exception) + navigate(MainNavigation.Snackbar(exception.message ?: "Unknown Error")) + emit(MainResult.PullToRefreshError) + } + } + is MainAction.LoadData -> { + try { + emit(MainResult.Loading) + val characters = mainRepository.fetchCharacters() + emit(MainResult.LoadingComplete(characters)) + } catch (exception: Exception) { + Timber.e(exception) + navigate(MainNavigation.Snackbar(exception.message ?: "Unknown Error")) + emit(MainResult.LoadingError) + } + } + is MainAction.LoadDescription -> { + try { + emit(MainResult.DescriptionResult.DescriptionLoading(action.characterId)) + val character = mainRepository.fetchCharacter(action.characterId) + delay(2000L) + emit(MainResult.DescriptionResult.DescriptionLoadComplete(character.id, character.description)) + } catch (exception : Exception) { + navigate(MainNavigation.Snackbar(exception.message ?: "Unknown Error")) + Timber.e(exception) + emit(MainResult.DescriptionResult.DescriptionError(action.characterId, exception)) + } + } + } + }.flowOn(Dispatchers.IO) + .catch { exception -> + Timber.e(exception) + navigate(MainNavigation.Snackbar(exception.message ?: "Unknown Error")) + emit(MainResult.LoadingError) + } + + private fun actionToResultTransformer(actionsFlow: Flow): Flow { + return actionsFlow.flatMapMerge { + loadingResult(actionsFlow.ofType(MainAction.LoadData::class.java)) + .merge(loadDescriptionResult(actionsFlow.ofType(MainAction.LoadDescription::class.java)), + pullToRefreshResult(actionsFlow.ofType(MainAction.PullToRefresh::class.java))) + } + } + + private fun loadDescriptionResult(actionsFlow: Flow): Flow { + return actionsFlow.flatMapMerge { action -> + flow { + emit(mainRepository.fetchCharacter(action.characterId)) + } + .onEach { delay(2000) } + .map { item -> + MainResult.DescriptionResult.DescriptionLoadComplete(item.id, item.description) as MainResult + } + .onStart { emit(MainResult.DescriptionResult.DescriptionLoading(action.characterId)) } + .catch { + navigate(MainNavigation.Snackbar(it.message ?: "Unknown Error")) + Timber.e(it) + emit(MainResult.DescriptionResult.DescriptionError(action.characterId, it)) + } + } + } + + private fun pullToRefreshResult(actionsFlow: Flow): Flow = + actionsFlow.flatMapMerge { + flow { + emit(MainResult.PullToRefreshing) + val characters = mainRepository.fetchCharacters() + emit(MainResult.PullToRefreshComplete(characters)) + } + .catch { exception -> + Timber.e(exception) + navigate(MainNavigation.Snackbar(exception.message + ?: "Unknown Error")) + emit(MainResult.PullToRefreshError) + } + } + + private fun loadingResult(actionsFlow: Flow): Flow = + actionsFlow.flatMapMerge { + flow { + emit(MainResult.Loading) + val characters = mainRepository.fetchCharacters() + emit(MainResult.LoadingComplete(characters)) + } + .onEach { delay(1000) } + .catch { exception -> + Timber.e(exception) + navigate(MainNavigation.Snackbar(exception.message ?: "Unknown Error")) + emit(MainResult.LoadingError) + } + } + + + private fun reduce(state: MainViewState, result: MainResult): MainViewState { + return state.reduce(result, resourceProvider) + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/dagger/AppModule.kt b/presentation/src/main/java/com/ragdroid/mvi/dagger/AppModule.kt index e78da36..7d7ecc4 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/dagger/AppModule.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/dagger/AppModule.kt @@ -9,6 +9,8 @@ import com.ragdroid.mvi.MarvelApplication import com.ragdroid.mvi.base.AppResourceProvider import com.ragdroid.mvi.base.ResourceProvider import com.ragdroid.mvi.helpers.AndroidHelpers +import com.ragdroid.mvi.helpers.DispatchProvider +import com.ragdroid.mvi.helpers.DispatchProviderImpl import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -37,4 +39,7 @@ class AppModule { fun provideResourceProvider(application: MarvelApplication): ResourceProvider = AppResourceProvider(application.applicationContext) + @Provides + fun provideDispatchers(dispatchProviderImpl: DispatchProviderImpl): DispatchProvider = dispatchProviderImpl + } \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/dagger/MainActivityModule.kt b/presentation/src/main/java/com/ragdroid/mvi/dagger/MainActivityModule.kt index 0a43631..fdad335 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/dagger/MainActivityModule.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/dagger/MainActivityModule.kt @@ -1,5 +1,6 @@ package com.ragdroid.mvi.dagger +import com.ragdroid.mvi.characters.CharactersFragment import com.ragdroid.mvi.main.MainFragment import dagger.Module import dagger.android.ContributesAndroidInjector @@ -10,7 +11,11 @@ import dagger.android.ContributesAndroidInjector @Module abstract class MainActivityModule { - @ContributesAndroidInjector() + @ContributesAndroidInjector @FragmentScope internal abstract fun mainActivityFragment(): MainFragment + + @ContributesAndroidInjector + @FragmentScope + internal abstract fun charactersFragment(): CharactersFragment } \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelFactory.kt b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelFactory.kt index e979ff4..6fba1ff 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelFactory.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelFactory.kt @@ -12,12 +12,6 @@ import kotlin.reflect.KClass * Thanks to https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 */ -@Singleton -class ViewModelFactory @Inject constructor(private val viewModels: MutableMap, Provider>): ViewModelProvider.Factory { +class ViewModelFactory @Inject constructor(private val viewModels: Map, @JvmSuppressWildcards Provider>): ViewModelProvider.Factory { override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T -} - -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) -@kotlin.annotation.Retention(AnnotationRetention.RUNTIME) -@MapKey -internal annotation class ViewModelKey(val value: KClass) \ No newline at end of file +} \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelKey.java b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelKey.java new file mode 100644 index 0000000..b86528a --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelKey.java @@ -0,0 +1,22 @@ +package com.ragdroid.mvi.dagger; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import androidx.lifecycle.ViewModel; +import dagger.MapKey; + +/** + * Workaround in Java due to Dagger/Kotlin not playing well together as of now + * https://github.com/google/dagger/issues/1478 + */ +@MapKey +@Documented +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ViewModelKey { + Class value(); +} diff --git a/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelModule.kt b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelModule.kt index 4155429..91b6096 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelModule.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/dagger/ViewModelModule.kt @@ -2,6 +2,7 @@ package com.ragdroid.mvi.dagger import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.ragdroid.mvi.characters.CharactersViewModel import com.ragdroid.mvi.viewmodel.MainFragmentViewModel import dagger.Binds import dagger.Module @@ -18,4 +19,9 @@ abstract class ViewModelModule { @ViewModelKey(MainFragmentViewModel::class) internal abstract fun mainViewModel(viewModel: MainFragmentViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(CharactersViewModel::class) + internal abstract fun charactersViewModel(viewModel: CharactersViewModel): ViewModel + } \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/helpers/DispatchProvider.kt b/presentation/src/main/java/com/ragdroid/mvi/helpers/DispatchProvider.kt new file mode 100644 index 0000000..fb14504 --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/helpers/DispatchProvider.kt @@ -0,0 +1,19 @@ +package com.ragdroid.mvi.helpers + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +interface DispatchProvider { + fun main(): CoroutineDispatcher + fun io(): CoroutineDispatcher + fun computation(): CoroutineDispatcher +} + +class DispatchProviderImpl @Inject constructor(): DispatchProvider { + override fun main(): CoroutineDispatcher = Dispatchers.Main + + override fun io(): CoroutineDispatcher = Dispatchers.IO + + override fun computation(): CoroutineDispatcher = Dispatchers.Default +} \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/helpers/FlowEx.kt b/presentation/src/main/java/com/ragdroid/mvi/helpers/FlowEx.kt new file mode 100644 index 0000000..0020b5d --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/helpers/FlowEx.kt @@ -0,0 +1,71 @@ +package com.ragdroid.mvi.helpers + +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +fun Flow.mergeWith(other: Flow): Flow = channelFlow { + // collect from one coroutine and send it + launch { + collect { send(it) } + } + // collect and send from this coroutine, too, concurrently + other.collect { send(it) } +} + +fun Flow.merge(other: Flow, + other2: Flow): Flow = channelFlow { + // collect from one coroutine and send it + launch { + collect { send(it) } + } + // collect and send from this coroutine, too, concurrently + launch { + other.collect { + send(it) + } + } + launch { + other2.collect { + send(it) + } + } +} + +fun Flow.merge3(other: Flow, + other2: Flow, + other3: Flow): Flow = channelFlow { + // collect from one coroutine and send it + launch { + collect { send(it) } + } + // collect and send from this coroutine, too, concurrently + launch { + other.collect { + send(it) + } + } + launch { + other2.collect { + send(it) + } + } + launch { + other3.collect { + send(it) + } + } +} + +fun Flow.ofType(clazz: Class): Flow { + return filter { + clazz.isInstance(it) + }.cast(clazz) +} + +fun Flow.cast(clazz: Class): Flow { + return map { + clazz.cast(it) + } +} + + diff --git a/presentation/src/main/java/com/ragdroid/mvi/helpers/SpaceItemDecoration.kt b/presentation/src/main/java/com/ragdroid/mvi/helpers/SpaceItemDecoration.kt new file mode 100644 index 0000000..d6058f5 --- /dev/null +++ b/presentation/src/main/java/com/ragdroid/mvi/helpers/SpaceItemDecoration.kt @@ -0,0 +1,25 @@ +package com.ragdroid.mvi.helpers + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class SpaceItemDecoration( + private val space: Int, + private val shouldShowLeft: Boolean = true, + private val shouldShowRight: Boolean = true, + private val shouldShowTop: Boolean = true, + private val shouldShowBottom: Boolean = true +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + if (shouldShowLeft) + outRect.left = space + if (shouldShowRight) + outRect.right = space + if (shouldShowBottom) + outRect.bottom = space + if (shouldShowTop) + outRect.top = space + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/items/LoaderItem.kt b/presentation/src/main/java/com/ragdroid/mvi/items/LoaderItem.kt deleted file mode 100644 index afee87c..0000000 --- a/presentation/src/main/java/com/ragdroid/mvi/items/LoaderItem.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.ragdroid.mvi.items - -import androidx.databinding.DataBindingUtil -import android.view.View -import com.fueled.reclaim.BaseItem -import com.fueled.reclaim.BaseViewHolder -import com.ragdroid.mvi.R -import com.ragdroid.mvi.databinding.ItemCharacterBinding -import com.ragdroid.mvi.models.CharacterItemState - -/** - * Created by garimajain on 22/11/17. - */ -class LoaderItem(val state: CharacterItemState) : - BaseItem(state, null) { - - override fun updateItemViews() { - viewHolder.binding?.state = state - } - - override fun onCreateViewHolder(view: View): ViewHolder = ViewHolder(view) - - override fun getType(): Enum> = ItemTypes.CHARACTER_MARVEL - - override fun getLayoutId(): Int = R.layout.item_character - - class ViewHolder(view: View): BaseViewHolder(view) { - val binding: ItemCharacterBinding? = DataBindingUtil.bind(view) - } -} \ No newline at end of file diff --git a/presentation/src/main/java/com/ragdroid/mvi/main/MainActivity.kt b/presentation/src/main/java/com/ragdroid/mvi/main/MainActivity.kt index f7783b9..30444d6 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/main/MainActivity.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/main/MainActivity.kt @@ -4,6 +4,7 @@ import android.os.Bundle import androidx.lifecycle.ViewModelProviders import com.google.android.material.snackbar.Snackbar import com.ragdroid.mvi.R +import com.ragdroid.mvi.characters.CharactersFragment import com.ragdroid.mvi.databinding.ActivityMainBinding import com.ragdroid.mvi.helpers.BindActivity import com.ragdroid.mvi.viewmodel.MainViewModel @@ -18,7 +19,6 @@ class MainActivity : DaggerAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setSupportActionBar(binding.toolbar) viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java) openFragment() @@ -31,10 +31,15 @@ class MainActivity : DaggerAppCompatActivity() { private fun openFragment() { val fragmentManager = supportFragmentManager - val fragmentTransaction = fragmentManager.beginTransaction() - val fraggy = MainFragment() - fragmentTransaction.replace(R.id.fragment_container, fraggy) - fragmentTransaction.commit() + + val fragment = supportFragmentManager.findFragmentByTag("fraggy") + if (fragment == null) { + val fragmentTransaction = fragmentManager.beginTransaction() +// val fraggy = MainFragment() + val fraggy = CharactersFragment() + fragmentTransaction.replace(R.id.fragment_container, fraggy, "fraggy") + fragmentTransaction.commit() + } } } diff --git a/presentation/src/main/java/com/ragdroid/mvi/main/MainFragment.kt b/presentation/src/main/java/com/ragdroid/mvi/main/MainFragment.kt index 158dd09..03f814a 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/main/MainFragment.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/main/MainFragment.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProviders -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.ragdroid.mvvmi.core.NavigationState @@ -18,6 +17,7 @@ import com.jakewharton.rxbinding2.support.v4.widget.refreshes import com.ragdroid.mvi.R import com.ragdroid.mvi.databinding.FragmentMainBinding import com.ragdroid.mvi.helpers.BindFragment +import com.ragdroid.mvi.helpers.SpaceItemDecoration import com.ragdroid.mvi.items.CharacterItem import com.ragdroid.mvi.models.CharacterItemPresenter import com.ragdroid.mvi.viewmodel.MainFragmentViewModel @@ -26,6 +26,7 @@ import dagger.android.support.DaggerFragment import io.reactivex.BackpressureStrategy import io.reactivex.Flowable import io.reactivex.processors.PublishProcessor +import jp.wasabeef.recyclerview.animators.FadeInAnimator import timber.log.Timber import javax.inject.Inject @@ -44,7 +45,6 @@ class MainFragment : DaggerFragment(), private val adapter: ItemsViewAdapter by lazy(LazyThreadSafetyMode.NONE) { ItemsViewAdapter(context) } - private val descriptionClickProcessor: PublishProcessor = PublishProcessor.create() override lateinit var viewModel: MainFragmentViewModel @@ -55,25 +55,30 @@ class MainFragment : DaggerFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.toolbar.setTitle(R.string.title_rx) val manager = LinearLayoutManager(context) - val decoration = DividerItemDecoration(context, RecyclerView.VERTICAL) + val decoration = SpaceItemDecoration(resources.getDimensionPixelOffset(R.dimen.keyline_1), false, false, true, false) manager.orientation = RecyclerView.VERTICAL binding.listView.layoutManager = manager binding.listView.adapter = adapter + binding.listView.itemAnimator = FadeInAnimator() binding.listView.addItemDecoration(decoration) setupViewModel() super.onMviViewCreated(savedInstanceState) } - override fun provideActions() = Flowable.merge(loadingIntent(), pullToRefreshIntent(), loadDescription()) - private fun setupViewModel() { viewModel = ViewModelProviders.of(this, viewModelFactory).get(MainFragmentViewModel::class.java) + binding.refreshLayout.setOnRefreshListener { + viewModel.onAction(MainAction.PullToRefresh) + } + viewModel.onAction(MainAction.LoadData) } + private val descriptionClickProcessor = PublishProcessor.create() override fun onCharacterDescriptionClicked(itemId: Long) { - descriptionClickProcessor.onNext(MainAction.LoadDescription(itemId)) + viewModel.onAction(MainAction.LoadDescription(itemId)) } override fun getItemPresenter(): CharacterItemPresenter { @@ -81,9 +86,6 @@ class MainFragment : DaggerFragment(), } - private fun pullToRefreshIntent(): Flowable = - binding.refreshLayout.refreshes().toFlowable(BackpressureStrategy.DROP).map { MainAction.PullToRefresh } - private fun loadingIntent(): Flowable = Flowable.just(MainAction.LoadData) private fun loadDescription(): Flowable { @@ -92,34 +94,24 @@ class MainFragment : DaggerFragment(), override fun render(state: MainViewState) { Timber.d("got state $state") - binding.model = state - when { - state.pullToRefreshError != null -> return - - state.loadingError != null -> { - adapter.clearAllRecyclerItems() - return - } - - else -> { - val characterModelList = - state.characters.map { - CharacterItem(it, this) - } - adapter.replaceItems(characterModelList, true) - } - } + binding.refreshing = state.loadingState == MainViewState.LoadingState.PullToRefreshing + binding.loading = state.loadingState == MainViewState.LoadingState.Loading + val characterModelList = + state.characters.map { + CharacterItem(it, this) + } + adapter.replaceItems(characterModelList, true) } override val lifecycleOwner: LifecycleOwner - get() = this + get() = this override fun navigate(navigationState: NavigationState) { when (navigationState) { - is MainNavigation.Snackbar -> Snackbar.make(binding.root, navigationState.message, Snackbar.LENGTH_SHORT) + is MainNavigation.Snackbar -> Snackbar.make(binding.root, navigationState.message, Snackbar.LENGTH_SHORT).show() else -> {//do nothing - } + } } } diff --git a/presentation/src/main/java/com/ragdroid/mvi/main/MainViewState.kt b/presentation/src/main/java/com/ragdroid/mvi/main/MainViewState.kt index c4c5e93..3e38675 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/main/MainViewState.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/main/MainViewState.kt @@ -9,70 +9,60 @@ import com.ragdroid.mvvmi.core.MviResult import com.ragdroid.mvvmi.core.MviState import com.ragdroid.mvvmi.core.NavigationState -/** - * State of the MainView - * Created by garimajain on 22/11/17. - */ -data class MainViewState( - val loading: Boolean, - val characters: List, - val loadingError: Throwable?, - val pullToRefreshing: Boolean, - val pullToRefreshError: Throwable?): MviState { +data class MainViewState(val characters: List, + val emptyState: EmptyState, + val loadingState: LoadingState): MviState { + + sealed class EmptyState { + object None: EmptyState() + object NoData: EmptyState() + object NoInternet: EmptyState() + } + + sealed class LoadingState { + object None: LoadingState() + object Loading: LoadingState() + object PullToRefreshing: LoadingState() + } companion object Factory { - fun init() = MainViewState( - loading = true, - characters = emptyList(), - loadingError = null, - pullToRefreshing = false, - pullToRefreshError = null - ) + fun init() = MainViewState(emptyList(), EmptyState.NoData, LoadingState.None) } fun reduce(result: MainResult, resources: ResourceProvider): MainViewState { - val characters = characters return when (result) { - is MainResult.Loading -> copy( - loading = true, - loadingError = null) - is MainResult.LoadingError -> copy( - loading = false, - loadingError = result.throwable) + is MainResult.Loading -> copy(loadingState = LoadingState.Loading) + is MainResult.LoadingError -> copy(loadingState = LoadingState.None, + emptyState = EmptyState.NoData) is MainResult.LoadingComplete -> { - val characterStates = reduceCharactersList(characters, result.characters, resources) - copy( - loading = false, - loadingError = null, - characters = characterStates) + val characterStates = reduceCharactersList(null, result.characters, resources) + copy(characterStates, loadingState = LoadingState.None, + emptyState = EmptyState.None) } - is MainResult.PullToRefreshing -> copy( - loading = false, - pullToRefreshing = true, - pullToRefreshError = null) - is MainResult.PullToRefreshError -> copy( - pullToRefreshing = false, - pullToRefreshError = result.throwable) - is MainResult.PullToRefreshComplete -> copy( - pullToRefreshing = false, - pullToRefreshError = null, - characters = reduceCharactersList(characters, result.characters, resources)) + is MainResult.PullToRefreshing -> { + copy(loadingState = LoadingState.PullToRefreshing) + } + is MainResult.PullToRefreshError -> copy(loadingState = LoadingState.None) + is MainResult.PullToRefreshComplete -> + copy(characters = reduceCharactersList(characters, result.characters, resources), + loadingState = LoadingState.None, + emptyState = EmptyState.None) is MainResult.DescriptionResult -> { + val previousCharacters = characters val previousItemState = findItemWithId(result.characterId) - val previousItemStateIndex = characters.indexOf(previousItemState) + val previousItemStateIndex = previousCharacters.indexOf(previousItemState) val newItemState = previousItemState.reduce(resources, result) - val newCharactersList = characters.slice(0 until previousItemStateIndex) + val newCharactersList = previousCharacters.slice(0 until previousItemStateIndex) .plus(listOf(newItemState)) - .plus(characters.slice(previousItemStateIndex + 1 until characters.size)) + .plus(previousCharacters.slice(previousItemStateIndex + 1 until previousCharacters.size)) copy(characters = newCharactersList) } - } } @@ -81,8 +71,7 @@ data class MainViewState( characters.find { it.characterId == characterId }!! - - private fun reduceCharactersList(previousStateList: List, + private fun reduceCharactersList(previousStateList: List?, resultList: List, resources: ResourceProvider): List { fun initialState(result: CharacterMarvel) = @@ -95,6 +84,7 @@ data class MainViewState( } + sealed class MainAction: MviAction { object PullToRefresh: MainAction() object LoadData: MainAction() @@ -104,17 +94,19 @@ sealed class MainAction: MviAction { sealed class MainResult: MviResult { object Loading: MainResult() - data class LoadingError(val throwable: Throwable): MainResult() + object LoadingError: MainResult() data class LoadingComplete(val characters: List): MainResult() object PullToRefreshing: MainResult() - data class PullToRefreshError(val throwable: Throwable): MainResult() + object PullToRefreshError: MainResult() data class PullToRefreshComplete(val characters: List): MainResult() sealed class DescriptionResult(val characterId: Long) : MainResult() { data class DescriptionLoading(private val id: Long) : DescriptionResult(id) - data class DescriptionError(private val id: Long, val throwable: Throwable): DescriptionResult(id) - data class DescriptionLoadComplete(private val id: Long, val description: String): DescriptionResult(id) + data class DescriptionError(private val id: Long, + val throwable: Throwable): DescriptionResult(id) + data class DescriptionLoadComplete(private val id: Long, + val description: String): DescriptionResult(id) } } diff --git a/presentation/src/main/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModel.kt b/presentation/src/main/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModel.kt index 8397722..a1bec71 100644 --- a/presentation/src/main/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModel.kt +++ b/presentation/src/main/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModel.kt @@ -3,26 +3,31 @@ package com.ragdroid.mvi.viewmodel import com.ragdroid.data.MainRepository import com.ragdroid.mvi.base.ResourceProvider import com.ragdroid.mvi.main.MainAction +import com.ragdroid.mvi.main.MainNavigation import com.ragdroid.mvi.main.MainResult import com.ragdroid.mvi.main.MainViewState import com.ragdroid.mvvmi.core.MviViewModel import io.reactivex.Flowable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.rx2.rxFlowable +import kotlinx.coroutines.rx2.rxSingle import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject class MainFragmentViewModel @Inject constructor(private val resourceProvider: ResourceProvider, - private val repository: MainRepository): + private val mainRepository: MainRepository): MviViewModel(MainViewState.init()) { + init { + processActions() + } + override fun actionsToResultTransformer(actions: Flowable): Flowable = - actions.publish { shared -> - Flowable.merge(loadingResult(shared.ofType(MainAction.LoadData::class.java)), - loadDescriptionResult(shared.ofType(MainAction.LoadDescription::class.java)), - pullToRefreshResult(shared.ofType(MainAction.PullToRefresh::class.java))) + Flowable.merge(loadingResult(actions.ofType(MainAction.LoadData::class.java)), + loadDescriptionResult(actions.ofType(MainAction.LoadDescription::class.java)), + pullToRefreshResult(actions.ofType(MainAction.PullToRefresh::class.java))) - } override fun reduce(previousState: MainViewState, result: MainResult): MainViewState { return previousState.reduce(result, resourceProvider) @@ -33,7 +38,8 @@ class MainFragmentViewModel @Inject constructor(private val resourceProvider: Re return loadDescriptionActionStream .observeOn(Schedulers.io()) .flatMap { action -> - repository.fetchCharacter(action.characterId).toFlowable() + + mainRepository.fetchCharacterSingle(action.characterId).toFlowable() .delay(2000, TimeUnit.MILLISECONDS, Schedulers.computation()) .map { item -> MainResult.DescriptionResult.DescriptionLoadComplete(item.id, item.description) as MainResult @@ -41,6 +47,7 @@ class MainFragmentViewModel @Inject constructor(private val resourceProvider: Re .startWith(MainResult.DescriptionResult.DescriptionLoading(action.characterId)) .onErrorReturn { error -> Timber.e(error) + navigate(MainNavigation.Snackbar(error.message ?: "Unknown Error")) MainResult.DescriptionResult.DescriptionError(action.characterId, error) } } @@ -50,21 +57,29 @@ class MainFragmentViewModel @Inject constructor(private val resourceProvider: Re return pullToRefreshActionStream .observeOn(Schedulers.io()) .flatMap { ignored -> - repository.fetchCharacters().toFlowable() + mainRepository.fetchCharactersSingle().toFlowable() .map { items -> MainResult.PullToRefreshComplete(items) as MainResult } .startWith(MainResult.PullToRefreshing) - .onErrorReturn { error -> MainResult.PullToRefreshError(error) } + .onErrorReturn { error -> + navigate(MainNavigation.Snackbar(error.message ?: "Unknown Error")) + MainResult.PullToRefreshError + } } } - private fun loadingResult(loadDataActionStream: Flowable): Flowable { + private fun loadingResult(loadDataActionStream: Flowable) + : Flowable { return loadDataActionStream .observeOn(Schedulers.io()) .flatMap { ignored -> - repository.fetchCharacters().toFlowable() + mainRepository.fetchCharactersSingle().toFlowable() .map { states -> MainResult.LoadingComplete(states) as MainResult } .startWith(MainResult.Loading) - .onErrorReturn(MainResult::LoadingError) + .onErrorReturn { + error -> + navigate(MainNavigation.Snackbar(error.message ?: "Unknown Error")) + MainResult.LoadingError + } } } diff --git a/presentation/src/main/res/layout/activity_main.xml b/presentation/src/main/res/layout/activity_main.xml index dec425a..b643949 100644 --- a/presentation/src/main/res/layout/activity_main.xml +++ b/presentation/src/main/res/layout/activity_main.xml @@ -8,24 +8,14 @@ android:layout_height="match_parent" tools:context="com.ragdroid.mvi.main.MainActivity"> - - - - - - - + android:layout_height="match_parent" + layout="@layout/content_main"/> + xmlns:tools="http://schemas.android.com/tools" + xmlns:bind="http://schemas.android.com/apk/res-auto" + xmlns:app="http://schemas.android.com/apk/res-auto"> + name="loading" + type="Boolean"/> + + + + + + + + bind:refreshing="@{refreshing}"> + android:layout_height="wrap_content"/> + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:bind="http://schemas.android.com/apk/res-auto"> @@ -15,78 +15,85 @@ type="com.ragdroid.mvi.models.CharacterItemPresenter"/> - - + + - + - + - + - + - - + + + \ No newline at end of file diff --git a/presentation/src/main/res/values/dimens.xml b/presentation/src/main/res/values/dimens.xml index d80e9fc..99a309f 100644 --- a/presentation/src/main/res/values/dimens.xml +++ b/presentation/src/main/res/values/dimens.xml @@ -35,6 +35,6 @@ 10sp 100dp 48dp - 100dp + 60dp diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index f2fe527..e176499 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ ]]> Description + Flow Characters + Marvel Characters diff --git a/presentation/src/test/java/com/ragdroid/mvi/characters/CharactersViewModelTest.kt b/presentation/src/test/java/com/ragdroid/mvi/characters/CharactersViewModelTest.kt new file mode 100644 index 0000000..8e181d9 --- /dev/null +++ b/presentation/src/test/java/com/ragdroid/mvi/characters/CharactersViewModelTest.kt @@ -0,0 +1,72 @@ +package com.ragdroid.mvi.characters + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.ragdroid.data.MainRepository +import com.ragdroid.mvi.TestDataFactory +import com.ragdroid.mvi.base.ResourceProvider +import com.ragdroid.mvi.main.MainAction +import com.ragdroid.mvi.viewmodel.BaseUnitTest +import com.ragdroid.mvi.viewmodel.blockingObserve +import io.reactivex.Single +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test + +class CharactersViewModelTest: BaseUnitTest() { + + val resourceProvider: ResourceProvider = mock() { + on { getString(any()) } doReturn "Description" + } + + val mainRepository: MainRepository = mock() { + onBlocking { fetchCharacters() } doReturn TestDataFactory.mockCharacters + on { fetchCharactersSingle() } doReturn Single.just(TestDataFactory.mockCharacters) + onBlocking { fetchCharacter(1234) } doReturn TestDataFactory.marcelCharacter1 + } + + + + private lateinit var viewmodel: CharactersViewModel + + + @Before + fun setUp() { + super.setup() + viewmodel = CharactersViewModel(mainRepository, resourceProvider, testDispatcherProvider) + viewmodel.processActions() + } + + @Test + fun testLoadData() { + + + viewmodel.onAction(MainAction.LoadData) + + testDispatcher.advanceTimeBy(2000L) + + val state = viewmodel.stateLiveData.blockingObserve() + + println(state) + + assertTrue(state!!.characters.isNotEmpty()) + + } + + @Test + fun testPullToRefresh() { + + viewmodel.onAction(MainAction.PullToRefresh) + + val state = viewmodel.stateLiveData.blockingObserve() + + println(state) + + assertTrue(state!!.characters.isNotEmpty()) + + } +} \ No newline at end of file diff --git a/presentation/src/test/java/com/ragdroid/mvi/viewmodel/BaseUnitTest.kt b/presentation/src/test/java/com/ragdroid/mvi/viewmodel/BaseUnitTest.kt index cda0387..3a7efd5 100644 --- a/presentation/src/test/java/com/ragdroid/mvi/viewmodel/BaseUnitTest.kt +++ b/presentation/src/test/java/com/ragdroid/mvi/viewmodel/BaseUnitTest.kt @@ -1,11 +1,17 @@ package com.ragdroid.mvi.viewmodel import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.ragdroid.mvi.helpers.DispatchProvider import io.reactivex.android.plugins.RxAndroidPlugins import io.reactivex.plugins.RxJavaPlugins import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.TestScheduler +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.setMain import org.junit.AfterClass +import org.junit.Before import org.junit.BeforeClass import org.junit.Rule @@ -16,6 +22,23 @@ open class BaseUnitTest { var instantExecutorRule = InstantTaskExecutorRule() + var testDispatcher = TestCoroutineDispatcher() + + var testDispatcherProvider = object: DispatchProvider { + + override fun main(): CoroutineDispatcher = testDispatcher + + override fun io(): CoroutineDispatcher = testDispatcher + + override fun computation(): CoroutineDispatcher = testDispatcher + + } + + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + companion object { @JvmStatic val testScheduler = TestScheduler() diff --git a/presentation/src/test/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModelTest.kt b/presentation/src/test/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModelTest.kt index 4a942ea..a6440d3 100644 --- a/presentation/src/test/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModelTest.kt +++ b/presentation/src/test/java/com/ragdroid/mvi/viewmodel/MainFragmentViewModelTest.kt @@ -1,17 +1,14 @@ package com.ragdroid.mvi.viewmodel -import com.nhaarman.mockito_kotlin.any -import com.nhaarman.mockito_kotlin.doReturn -import com.nhaarman.mockito_kotlin.mock +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock import com.ragdroid.data.MainRepository -import com.ragdroid.data.base.SchedulerProvider import com.ragdroid.mvi.TestDataFactory import com.ragdroid.mvi.base.ResourceProvider import com.ragdroid.mvi.main.MainAction import io.reactivex.Flowable import io.reactivex.Single -import io.reactivex.schedulers.TestScheduler -import junit.framework.Assert.assertNotNull import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit @@ -24,8 +21,8 @@ class MainFragmentViewModelTest: BaseUnitTest() { } val mainRepository: MainRepository = mock() { - on { fetchCharacters() } doReturn Single.just(TestDataFactory.mockCharacters) - on { fetchCharacter(1234) } doReturn Single.just(TestDataFactory.marcelCharacter1) + on { fetchCharactersSingle() } doReturn Single.just(TestDataFactory.mockCharacters) + on { fetchCharacterSingle(1234) } doReturn Single.just(TestDataFactory.marcelCharacter1) } private lateinit var viewmodel: MainFragmentViewModel @@ -41,7 +38,7 @@ class MainFragmentViewModelTest: BaseUnitTest() { val subscriber = viewmodel.stateFlowable().test() - viewmodel.processActions(Flowable.just(MainAction.LoadData)) + viewmodel.onAction(MainAction.LoadData) testScheduler.triggerActions() testScheduler.advanceTimeBy(3000, TimeUnit.MILLISECONDS)