Milestones/ms1 (#16)

* Closes #11

* Closes #13

* Closes #17

* Closes #18

* Closes #19

* Closes #6

* Closes #3

* Closes #12

* Closes #15
This commit is contained in:
Melihcan Aksoy
2019-07-26 13:38:51 +02:00
committed by Melih Aksoy
parent 11889446cb
commit 625776609d
103 changed files with 4367 additions and 716 deletions

View File

@@ -6,6 +6,7 @@ jobs:
- image: circleci/android:api-28 - image: circleci/android:api-28
environment: environment:
JVM_OPTS: -Xmx3200m JVM_OPTS: -Xmx3200m
CODECOV_TOKEN: "cd1376e1-2cfd-49a2-9f25-03ef69056b4d"
steps: steps:
- checkout - checkout
- restore_cache: - restore_cache:
@@ -32,7 +33,10 @@ jobs:
# Tests # Tests
- run: - run:
name: Tests name: Tests
command: fastlane test_all command: |
fastlane test_all
./gradlew jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/ - store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: build/reports/tests path: build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/ - store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
/projectFilesBackup
# Project reports # Project reports
/reports /reports

3130
.gradletasknamecache Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':core') implementation project(':core')
implementation project(':features:list') implementation project(':features:launches')
implementation project(':features:detail') implementation project(':features:detail')
implementation libraries.coroutines implementation libraries.coroutines

View File

@@ -1,6 +1,8 @@
package com.melih.rocketscience.di package com.melih.rocketscience.di
import com.melih.core.di.CoreComponent import com.melih.core.di.CoreComponent
import com.melih.detail.di.DetailModule
import com.melih.list.di.LaunchesFeatureModule
import com.melih.rocketscience.App import com.melih.rocketscience.App
import dagger.Component import dagger.Component
import dagger.android.AndroidInjectionModule import dagger.android.AndroidInjectionModule
@@ -8,7 +10,10 @@ import dagger.android.AndroidInjector
@AppScope @AppScope
@Component( @Component(
modules = [AndroidInjectionModule::class, AppModule::class], modules = [AndroidInjectionModule::class,
LaunchesFeatureModule::class,
DetailModule::class],
dependencies = [CoreComponent::class] dependencies = [CoreComponent::class]
) )
interface AppComponent : AndroidInjector<App> { interface AppComponent : AndroidInjector<App> {

View File

@@ -1,26 +0,0 @@
package com.melih.rocketscience.di
import com.melih.detail.di.DetailContributor
import com.melih.detail.ui.DetailActivity
import com.melih.list.di.LaunchesContributor
import com.melih.list.ui.LaunchesActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
@Module
abstract class AppModule {
@ContributesAndroidInjector(
modules = [
LaunchesContributor::class
]
)
abstract fun launchesActivity(): LaunchesActivity
@ContributesAndroidInjector(
modules = [
DetailContributor::class
]
)
abstract fun detailActivity(): DetailActivity
}

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.3.40' ext.kotlin_version = '1.3.41'
ext.nav_version = '2.1.0-alpha06' ext.nav_version = '2.1.0-alpha06'
repositories { repositories {
@@ -9,10 +9,10 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.5.0-beta05' classpath 'com.android.tools.build:gradle:3.5.0-rc01'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18" classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18"
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.4.2.1" classpath "de.mannodermaus.gradle.plugins:android-junit5:1.5.0.0"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
@@ -22,6 +22,7 @@ buildscript {
plugins { plugins {
id "io.gitlab.arturbosch.detekt" version "1.0.0-RC14" id "io.gitlab.arturbosch.detekt" version "1.0.0-RC14"
id "org.jetbrains.dokka" version "0.9.18" id "org.jetbrains.dokka" version "0.9.18"
id "jacoco"
} }
allprojects { allprojects {
@@ -159,4 +160,9 @@ task projectDependencyGraph {
} }
} }
jacoco {
toolVersion = "0.8.4"
reportsDir = file("reports/jacoco")
}
apply from: "scripts/dependencies.gradle" apply from: "scripts/dependencies.gradle"

BIN
core/jacoco.exec Normal file

Binary file not shown.

View File

@@ -8,7 +8,6 @@ import androidx.databinding.ViewDataBinding
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.NavigationUI
import dagger.android.support.DaggerAppCompatActivity import dagger.android.support.DaggerAppCompatActivity
import kotlinx.coroutines.ExperimentalCoroutinesApi
const val NAV_HOST_FRAGMENT_TAG = "nav_host_fragment_tag" const val NAV_HOST_FRAGMENT_TAG = "nav_host_fragment_tag"
@@ -20,7 +19,6 @@ abstract class BaseActivity<T : ViewDataBinding> : DaggerAppCompatActivity() {
protected lateinit var binding: T protected lateinit var binding: T
protected lateinit var navHostFragment: NavHostFragment protected lateinit var navHostFragment: NavHostFragment
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@@ -12,7 +12,6 @@ import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.Reason
import kotlinx.coroutines.ExperimentalCoroutinesApi
/** /**
* Parent of all fragments. * Parent of all fragments.
@@ -41,7 +40,6 @@ abstract class BaseFragment<T : ViewDataBinding> : Fragment() {
return binding.root return binding.root
} }
@ExperimentalCoroutinesApi
protected fun showSnackbarWithAction(reason: Reason, block: () -> Unit) { protected fun showSnackbarWithAction(reason: Reason, block: () -> Unit) {
Snackbar.make( Snackbar.make(
binding.root, binding.root,

View File

@@ -0,0 +1,140 @@
package com.melih.core.base.paging
import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.State
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onState
import com.melih.repository.interactors.base.onSuccess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
const val INITIAL_PAGE = 0
/**
* Base class for all [pageKeyedDataSources][PageKeyedDataSource] in project.
*
* Purpose of this class is to ease handling of [result][Result]. It overrides [loadInitial], [loadAfter] and [loadBefore]
* so sources extends from base does not need to override them, they just need to provide way of loading data by overriding [loadDataForPage].
*
* [handleState] & [handleFailure] updates corresponding [liveData][LiveData] objects [stateData] & [reasonData],
* which can be used with [androidx.lifecycle.Transformations] to observe changes on the source state & error.
*
* This source has it's own [coroutineScope][CoroutineScope] that's backed up by a [SupervisorJob] to handle networking operations.
* It's cancelled automatically when source factory [invalidates][invalidate] the source.
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
// region Abstractions
abstract fun loadDataForPage(page: Int): Flow<Result<List<T>>> // Load next page(s)
// endregion
// region Properties
private val _stateData = MutableLiveData<State>()
private val _reasonData = MutableLiveData<Reason>()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
/**
* Observe [stateData] to get notified of state of data
*/
val stateData: LiveData<State>
get() = _stateData
/**
* Observe [reasonData] to get notified if an error occurs
*/
val reasonData: LiveData<Reason>
get() = _reasonData
// endregion
// region Functions
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, T>) {
// Looping through channel as we'll receive any state, error or data here
loadDataForPage(INITIAL_PAGE)
.onEach { result ->
result.onState(::handleState)
.onFailure(::handleFailure)
.onSuccess {
// When we receive data without any failures, we transform it and return list, also what's the value for next page
callback.onResult(
it,
INITIAL_PAGE,
INITIAL_PAGE + 1
)
}
}
.launchIn(coroutineScope)
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
// Key for which page to load is in params
val page = params.key
loadDataForPage(page)
.onEach { result ->
result
.onState(::handleState)
.onFailure(::handleFailure)
.onSuccess {
// When we receive data without any failures, we transform it and return list, also what's the value for next page
callback.onResult(
it,
page + 1
)
}
}
.launchIn(coroutineScope)
}
/**
* This loads previous pages, we don't have a use for it yet, so it's a no-op override
*/
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
// no-op
}
/**
* Default state handler which assigns given [state] to [stateData]
*
* @param state state of operation
*/
@CallSuper
protected fun handleState(state: State) {
_stateData.value = state
}
/**
* Default error handler which assign received [reason] to [reasonData]
*
* @param reason check [Reason] class for possible error types
*/
@CallSuper
protected fun handleFailure(reason: Reason) {
_reasonData.value = reason
}
/**
* Canceling [coroutineScope]
*/
@CallSuper
override fun invalidate() {
coroutineScope.cancel()
super.invalidate()
}
// endregion
}

View File

@@ -0,0 +1,42 @@
package com.melih.core.base.paging
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
/**
* Base [factory][DataSource.Factory] class for any [dataSource][DataSource]s in project.
*
* It's purpose is to provide latest source so [basePagingViewModel][be.mediahuis.core.base.viewmodel.BasePagingViewModel] can obtain
* [stateData][BasePagingDataSource.stateData] and [reasonData][BasePagingDataSource.reasonData] from it.
*
* This is done under the hood by telling this factory how to create a source by overriding [createSource].
*
* Purpose of this transmission is to encapuslate [basePagingDataSource][BasePagingDataSource].
*/
abstract class BasePagingFactory<T> : DataSource.Factory<Int, T>() {
// region Abstractions
abstract fun createSource(): BasePagingDataSource<T>
// endregion
// region Properties
private val _currentSource = MutableLiveData<BasePagingDataSource<T>>()
val currentSource: LiveData<BasePagingDataSource<T>>
get() = _currentSource
// endregion
// region Functions
override fun create(): DataSource<Int, T> = createSource().apply { _currentSource.postValue(this) }
/**
* Invalidating the [currentSource]
* by calling [be.mediahuis.core.base.paging.BasePagingDataSource.invalidate]
*/
fun invalidateDataSource() = currentSource.value?.invalidate()
// endregion
}

View File

@@ -3,8 +3,8 @@ package com.melih.core.base.recycler
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.databinding.ViewDataBinding import androidx.databinding.ViewDataBinding
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
/** /**
@@ -12,10 +12,10 @@ import androidx.recyclerview.widget.RecyclerView
* *
* *
*/ */
abstract class BaseListAdapter<T>( abstract class BasePagingListAdapter<T>(
callback: DiffUtil.ItemCallback<T>, callback: DiffUtil.ItemCallback<T>,
private val clickListener: (T) -> Unit private val clickListener: (T?) -> Unit
) : ListAdapter<T, BaseViewHolder<T>>(callback) { ) : PagedListAdapter<T, BaseViewHolder<T>>(callback) {
private var itemClickListener: ((T) -> Unit)? = null private var itemClickListener: ((T) -> Unit)? = null
@@ -48,12 +48,12 @@ abstract class BaseListAdapter<T>(
override fun onBindViewHolder(holder: BaseViewHolder<T>, position: Int) { override fun onBindViewHolder(holder: BaseViewHolder<T>, position: Int) {
val item = getItem(position) val item = getItem(position)
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
clickListener(item) clickListener(item)
} }
holder.bind(item) holder.bind(item)
} }
} }
@@ -68,5 +68,5 @@ abstract class BaseViewHolder<T>(binding: ViewDataBinding) : RecyclerView.ViewHo
* @param item entity * @param item entity
* @param position position from adapter * @param position position from adapter
*/ */
abstract fun bind(item: T) abstract fun bind(item: T?)
} }

View File

@@ -0,0 +1,70 @@
package com.melih.core.base.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import androidx.paging.toLiveData
import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.State
/**
* Base [ViewModel] for view models that will use [PagedList].
*
* Since data handling is done via [be.mediahuis.core.base.paging.BasePagingDataSource], this view model doesn't need
* a [kotlinx.coroutines.channels.ReceiveChannel] and will not provide any default operations of data, but instead will
* provde [pagedList] which should be observed and submitted.
*
* If paging won't be used, use [BaseViewModel] instead.
*/
abstract class BasePagingViewModel<T> : ViewModel() {
// region Abstractions
abstract val factory: BasePagingFactory<T>
abstract val config: PagedList.Config
// endregion
// region Properties
/**
* Observe [stateData] to get notified of state of data
*/
val stateData: LiveData<State> by lazy {
Transformations.switchMap(factory.currentSource) {
it.stateData
}
}
/**
* Observe [errorData] to get notified if an error occurs
*/
val errorData: LiveData<Reason> by lazy {
Transformations.switchMap(factory.currentSource) {
it.reasonData
}
}
/**
* Observe [pagedList] to submit list it provides
*/
val pagedList: LiveData<PagedList<T>> by lazy {
factory.toLiveData(config)
}
// endregion
// region Functions
fun refresh() {
factory.currentSource.value?.invalidate()
}
/**
* Retry loading data, incase there's difference between refresh and retry, should go here
*/
fun retry() {
factory.currentSource.value?.invalidate()
}
// endregion
}

View File

@@ -3,8 +3,10 @@ package com.melih.core.base.viewmodel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.State
import kotlinx.coroutines.launch
/** /**
* Base [ViewModel] for view models that will process data. * Base [ViewModel] for view models that will process data.
@@ -15,13 +17,19 @@ abstract class BaseViewModel<T> : ViewModel() {
// region Abstractions // region Abstractions
abstract fun loadData() abstract suspend fun loadData()
// endregion // endregion
init {
viewModelScope.launch {
loadData()
}
}
// region Properties // region Properties
private val _successData = MutableLiveData<T>() private val _successData = MutableLiveData<T>()
private val _stateData = MutableLiveData<Result.State>() private val _stateData = MutableLiveData<State>()
private val _errorData = MutableLiveData<Reason>() private val _errorData = MutableLiveData<Reason>()
/** /**
@@ -33,7 +41,7 @@ abstract class BaseViewModel<T> : ViewModel() {
/** /**
* Observe [stateData] to get notified of state of data * Observe [stateData] to get notified of state of data
*/ */
val stateData: LiveData<Result.State> val stateData: LiveData<State>
get() = _stateData get() = _stateData
/** /**
@@ -59,7 +67,7 @@ abstract class BaseViewModel<T> : ViewModel() {
* *
* @param state state of operation * @param state state of operation
*/ */
protected fun handleState(state: Result.State) { protected fun handleState(state: State) {
_stateData.value = state _stateData.value = state
} }
@@ -76,14 +84,18 @@ abstract class BaseViewModel<T> : ViewModel() {
* Reload data * Reload data
*/ */
fun refresh() { fun refresh() {
loadData() viewModelScope.launch {
loadData()
}
} }
/** /**
* Retry loading data, incase there's difference between refresh and retry, should go here * Retry loading data, incase there's difference between refresh and retry, should go here
*/ */
fun retry() { fun retry() {
loadData() viewModelScope.launch {
loadData()
}
} }
// endregion // endregion
} }

View File

@@ -1,8 +1,8 @@
package com.melih.core.di package com.melih.core.di
import android.app.Application import android.app.Application
import android.content.Context
import android.net.NetworkInfo import android.net.NetworkInfo
import com.melih.repository.persistence.LaunchesDatabase
import dagger.BindsInstance import dagger.BindsInstance
import dagger.Component import dagger.Component
import javax.inject.Singleton import javax.inject.Singleton
@@ -11,9 +11,9 @@ import javax.inject.Singleton
@Component(modules = [CoreModule::class]) @Component(modules = [CoreModule::class])
interface CoreComponent { interface CoreComponent {
fun getNetworkInfo(): NetworkInfo? fun getAppContext(): Context
fun getLaunchesDatabase(): LaunchesDatabase fun getNetworkInfo(): NetworkInfo?
@Component.Factory @Component.Factory
interface Factory { interface Factory {

View File

@@ -4,23 +4,16 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkInfo import android.net.NetworkInfo
import androidx.room.Room
import com.melih.repository.persistence.DB_NAME
import com.melih.repository.persistence.LaunchesDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import javax.inject.Singleton
@Module @Module
class CoreModule { class CoreModule {
@Provides @Provides
fun provideNetworkInfo(app: Application): NetworkInfo? = fun proivdeAppContext(app: Application): Context = app.applicationContext
(app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo
@Provides @Provides
@Singleton fun provideNetworkInfo(app: Application): NetworkInfo? =
fun provideLaunchesDatabase(app: Application) = (app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo
Room.databaseBuilder(app.applicationContext, LaunchesDatabase::class.java, DB_NAME)
.build()
} }

View File

@@ -1,5 +1,6 @@
package com.melih.core.extensions package com.melih.core.extensions
import android.view.MenuItem
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import com.melih.core.utils.ClearFocusQueryTextListener import com.melih.core.utils.ClearFocusQueryTextListener
@@ -8,4 +9,24 @@ import com.melih.core.utils.ClearFocusQueryTextListener
*/ */
fun CharSequence.containsIgnoreCase(other: CharSequence) = contains(other, true) fun CharSequence.containsIgnoreCase(other: CharSequence) = contains(other, true)
/**
* Adds [ClearFocusQueryTextListener] as [SearchView.OnQueryTextListener]
*/
fun SearchView.setOnQueryChangedListener(block: (String?) -> Unit) = setOnQueryTextListener(ClearFocusQueryTextListener(this, block)) fun SearchView.setOnQueryChangedListener(block: (String?) -> Unit) = setOnQueryTextListener(ClearFocusQueryTextListener(this, block))
/**
* Shortening set menu item expands / collapses
*/
fun MenuItem.onExpandOrCollapse(onExpand: () -> Unit, onCollapse: () -> Unit) {
setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
onCollapse()
return true
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
onExpand()
return true
}
})
}

View File

@@ -9,6 +9,7 @@ import com.google.android.material.snackbar.Snackbar
/** /**
* Simple behaviour for pushing views when snackbar is animating so none of views will remain under snackbar * Simple behaviour for pushing views when snackbar is animating so none of views will remain under snackbar
*/ */
@Suppress("UNUSED_PARAMETER")
class SnackbarBehaviour constructor( class SnackbarBehaviour constructor(
context: Context, context: Context,
attributeSet: AttributeSet attributeSet: AttributeSet

View File

@@ -6,19 +6,19 @@ import androidx.lifecycle.LiveData
import com.melih.core.observers.OneShotObserverWithLifecycle import com.melih.core.observers.OneShotObserverWithLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import java.util.concurrent.Executors
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BaseTestWithMainThread { abstract class BaseTestWithMainThread {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val dispatcher = TestCoroutineDispatcher()
@BeforeEach @BeforeEach
@ExperimentalCoroutinesApi
fun setUp() { fun setUp() {
Dispatchers.setMain(dispatcher) Dispatchers.setMain(dispatcher)
ArchTaskExecutor.getInstance() ArchTaskExecutor.getInstance()
@@ -32,12 +32,12 @@ abstract class BaseTestWithMainThread {
} }
@AfterEach @AfterEach
@ExperimentalCoroutinesApi
fun tearDown() { fun tearDown() {
Dispatchers.resetMain()
dispatcher.close()
ArchTaskExecutor.getInstance() ArchTaskExecutor.getInstance()
.setDelegate(null) .setDelegate(null)
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
} }
} }

View File

@@ -1,37 +1,32 @@
package com.melih.core.base package com.melih.core.base
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.viewmodel.BaseViewModel import com.melih.core.base.viewmodel.BaseViewModel
import io.mockk.coVerify
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** class BaseViewModelTest : BaseTestWithMainThread() {
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class BaseViewModelTest {
val baseVm = spyk(TestViewModel())
@Test @Test
fun `refresh should invoke loadData`() { fun `refresh should invoke loadData`() {
val baseVm = spyk(TestViewModel())
baseVm.refresh() baseVm.refresh()
verify(exactly = 1) { baseVm.loadData() } coVerify(exactly = 1) { baseVm.loadData() }
} }
@Test @Test
fun `retry should invoke loadData`() { fun `retry should invoke loadData`() {
val baseVm = spyk(TestViewModel())
baseVm.retry() baseVm.retry()
verify(exactly = 1) { baseVm.loadData() } coVerify(exactly = 1) { baseVm.loadData() }
} }
} }
class TestViewModel : BaseViewModel<Int>() { class TestViewModel : BaseViewModel<Unit>() {
override public fun loadData() { override suspend fun loadData() {
// no - op // no - op
} }
} }

View File

@@ -0,0 +1,166 @@
@file:UseExperimental(ExperimentalCoroutinesApi::class)
package com.melih.core.paging
import androidx.paging.PageKeyedDataSource
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.testObserve
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.GenericError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.State
import com.melih.repository.interactors.base.Success
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import org.amshove.kluent.shouldBeInstanceOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class BasePagingDataSourceTest : BaseTestWithMainThread() {
val source = spyk(TestSource())
val failureSource = spyk(TestFailureSource())
val data = 10
val errorMessage = "Generic Error"
@Nested
inner class BasePagingSource {
@Nested
inner class LoadInitial {
@Test
fun `should update state accordingly`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
runBlocking {
// Fake loading
source.loadInitial(params, callback)
source.stateData.testObserve {
it shouldBeInstanceOf State.Loading::class
}
}
}
@Test
fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadInitialParams<Int>(10, false)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
runBlocking {
// Fake loading
failureSource.loadInitial(params, callback)
failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class
}
}
}
}
@Nested
inner class LoadAfter {
@Test
fun `should update state accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
runBlocking {
// Fake loading
source.loadAfter(params, callback)
source.stateData.testObserve {
it shouldBeInstanceOf State.Loading::class
}
}
}
@Test
fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
runBlocking {
// Fake loading
failureSource.loadAfter(params, callback)
failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class
}
}
}
}
@Test
fun `should use loadDataForPage in loadInitial and transform emmited value`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
// Fake loading
source.loadInitial(params, callback)
// Make sure load initial called only once
verify(exactly = 1) { source.loadDataForPage(any()) }
// Notified callback
verify(exactly = 1) { callback.onResult(any(), any(), any()) }
}
@Test
fun `should use loadDataForPage in loadAfter and transform emmited value`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
// Fake loading
source.loadAfter(params, callback)
// Make sure load initial called only once
verify(exactly = 1) { source.loadDataForPage(any()) }
// Notified callback
verify(exactly = 1) { callback.onResult(any(), any()) }
}
}
inner class TestSource : BasePagingDataSource<Int>() {
val result = flow {
emit(State.Loading())
emit(Success(listOf(data)))
}
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result
}
inner class TestFailureSource : BasePagingDataSource<Int>() {
val result = flow {
emit(State.Loading())
emit(Failure(GenericError()))
}
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result
}
}

View File

@@ -0,0 +1,33 @@
package com.melih.core.paging
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory
import com.melih.core.testObserve
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.runBlocking
import org.amshove.kluent.shouldEqual
import org.junit.jupiter.api.Test
class BasePagingFactoryTest : BaseTestWithMainThread() {
val factory = spyk(TestFactory())
@Test
fun `create should update current source when it creates a new one`() {
val source = factory.create()
runBlocking {
factory.currentSource.testObserve {
it shouldEqual source
}
}
}
inner class TestFactory : BasePagingFactory<String>() {
override fun createSource(): BasePagingDataSource<String> = mockk(relaxed = true)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
docs/module_graph.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -15,8 +15,5 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest testImplementation testLibraries.coroutinesTest
} }

BIN
features/detail/jacoco.exec Normal file

Binary file not shown.

View File

@@ -1,7 +1,9 @@
package com.melih.detail.di package com.melih.detail.di
import com.melih.detail.di.modules.DetailBinds import com.melih.detail.di.modules.DetailBinds
import com.melih.detail.di.modules.DetailProvides
import com.melih.detail.ui.DetailFragment import com.melih.detail.ui.DetailFragment
import com.melih.list.di.scopes.DetailFragmentScope
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@@ -15,9 +17,11 @@ abstract class DetailContributor {
@ContributesAndroidInjector( @ContributesAndroidInjector(
modules = [ modules = [
DetailBinds::class DetailBinds::class,
DetailProvides::class
] ]
) )
@DetailFragmentScope
abstract fun detailFragment(): DetailFragment abstract fun detailFragment(): DetailFragment
// endregion // endregion
} }

View File

@@ -0,0 +1,24 @@
package com.melih.detail.di
import com.melih.detail.ui.DetailActivity
import com.melih.list.di.scopes.DetailScope
import dagger.Module
import dagger.android.ContributesAndroidInjector
/**
* Contributes fragments & view models in this module
*/
@Module
abstract class DetailModule {
// region Contributes
@ContributesAndroidInjector(
modules = [
DetailContributor::class
]
)
@DetailScope
abstract fun detailActivity(): DetailActivity
// endregion
}

View File

@@ -6,7 +6,6 @@ import com.melih.detail.ui.DetailViewModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import kotlinx.coroutines.ExperimentalCoroutinesApi
@Module @Module
abstract class DetailBinds { abstract class DetailBinds {
@@ -16,7 +15,6 @@ abstract class DetailBinds {
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(DetailViewModel::class) @ViewModelKey(DetailViewModel::class)
@ExperimentalCoroutinesApi
abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel
// endregion // endregion
} }

View File

@@ -0,0 +1,21 @@
package com.melih.detail.di.modules
import androidx.navigation.fragment.navArgs
import com.melih.detail.ui.DetailFragment
import com.melih.detail.ui.DetailFragmentArgs
import com.melih.repository.interactors.GetLaunchDetails
import dagger.Module
import dagger.Provides
@Module
class DetailProvides {
/**
* Provides launch detail params
*/
@Provides
fun provideGetLaunchDetailParams(fragment: DetailFragment): GetLaunchDetails.Params {
val args: DetailFragmentArgs by fragment.navArgs()
return GetLaunchDetails.Params(args.launchId)
}
}

View File

@@ -0,0 +1,7 @@
package com.melih.list.di.scopes
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class DetailFragmentScope

View File

@@ -0,0 +1,7 @@
package com.melih.list.di.scopes
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class DetailScope

View File

@@ -6,7 +6,6 @@ import com.melih.core.actions.EXTRA_LAUNCH_ID
import com.melih.core.base.lifecycle.BaseActivity import com.melih.core.base.lifecycle.BaseActivity
import com.melih.detail.R import com.melih.detail.R
import com.melih.detail.databinding.DetailActivityBinding import com.melih.detail.databinding.DetailActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
const val INVALID_LAUNCH_ID = -1L const val INVALID_LAUNCH_ID = -1L
@@ -14,13 +13,12 @@ class DetailActivity : BaseActivity<DetailActivityBinding>() {
// region Functions // region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true); supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true); supportActionBar?.setDisplayShowHomeEnabled(true)
} }
override fun getLayoutId(): Int = R.layout.activity_detail override fun getLayoutId(): Int = R.layout.activity_detail

View File

@@ -3,38 +3,28 @@ package com.melih.detail.ui
import android.os.Bundle import android.os.Bundle
import android.text.method.ScrollingMovementMethod import android.text.method.ScrollingMovementMethod
import android.view.View import android.view.View
import androidx.navigation.fragment.navArgs
import com.melih.core.base.lifecycle.BaseDaggerFragment import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.createFor import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe import com.melih.core.extensions.observe
import com.melih.detail.R import com.melih.detail.R
import com.melih.detail.databinding.DetailBinding import com.melih.detail.databinding.DetailBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
class DetailFragment : BaseDaggerFragment<DetailBinding>() { class DetailFragment : BaseDaggerFragment<DetailBinding>() {
// region Properties // region Properties
private val args: DetailFragmentArgs by navArgs()
@ExperimentalCoroutinesApi
private val viewModel: DetailViewModel private val viewModel: DetailViewModel
get() = viewModelFactory.createFor(this) get() = viewModelFactory.createFor(this)
// endregion // endregion
// region Functions // region Functions
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.tvDescription.movementMethod = ScrollingMovementMethod() binding.tvDescription.movementMethod = ScrollingMovementMethod()
binding.viewModel = viewModel binding.viewModel = viewModel
viewModel.createParamsFor(args.launchId)
viewModel.loadData()
// Observing error to show toast with retry action // Observing error to show toast with retry action
observe(viewModel.errorData) { observe(viewModel.errorData) {
showSnackbarWithAction(it) { showSnackbarWithAction(it) {

View File

@@ -1,24 +1,20 @@
package com.melih.detail.ui package com.melih.detail.ui
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.viewModelScope
import com.melih.core.base.viewmodel.BaseViewModel import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunchDetails import com.melih.repository.interactors.GetLaunchDetails
import kotlinx.coroutines.ExperimentalCoroutinesApi import com.melih.repository.interactors.base.handle
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ExperimentalCoroutinesApi
class DetailViewModel @Inject constructor( class DetailViewModel @Inject constructor(
private val getLaunchDetails: GetLaunchDetails private val getLaunchDetails: GetLaunchDetails,
private val getLaunchDetailsParams: GetLaunchDetails.Params
) : BaseViewModel<LaunchEntity>() { ) : BaseViewModel<LaunchEntity>() {
// region Properties // region Properties
private var params = GetLaunchDetails.Params(INVALID_LAUNCH_ID)
val rocketName = Transformations.map(successData) { val rocketName = Transformations.map(successData) {
it.rocket.name it.rocket.name
} }
@@ -38,18 +34,12 @@ class DetailViewModel @Inject constructor(
// region Functions // region Functions
fun createParamsFor(id: Long) {
params = GetLaunchDetails.Params(id)
}
/** /**
* Triggering interactor in view model scope * Triggering interactor in view model scope
*/ */
override fun loadData() { override suspend fun loadData() {
viewModelScope.launch { getLaunchDetails(getLaunchDetailsParams).collect {
getLaunchDetails(params).collect { it.handle(::handleState, ::handleFailure, ::handleSuccess)
it.handle(::handleState, ::handleFailure, ::handleSuccess)
}
} }
} }
// endregion // endregion

View File

@@ -1,4 +1,4 @@
package com.melih.list package com.melih.detail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -8,19 +8,17 @@ import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BaseTestWithMainThread { abstract class BaseTestWithMainThread {
@ExperimentalCoroutinesApi
protected val dispatcher = TestCoroutineDispatcher() protected val dispatcher = TestCoroutineDispatcher()
@BeforeEach @BeforeEach
@ExperimentalCoroutinesApi
fun setUp() { fun setUp() {
Dispatchers.setMain(dispatcher) Dispatchers.setMain(dispatcher)
} }
@AfterEach @AfterEach
@ExperimentalCoroutinesApi
fun tearDown() { fun tearDown() {
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines() dispatcher.cleanupTestCoroutines()

View File

@@ -1,7 +1,6 @@
package com.melih.detail package com.melih.detail
import com.melih.detail.ui.DetailViewModel import com.melih.detail.ui.DetailViewModel
import com.melih.list.BaseTestWithMainThread
import com.melih.repository.interactors.GetLaunchDetails import com.melih.repository.interactors.GetLaunchDetails
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
@@ -17,21 +16,20 @@ import org.junit.jupiter.api.Test
* *
* See [testing documentation](http://d.android.com/tools/testing). * See [testing documentation](http://d.android.com/tools/testing).
*/ */
@UseExperimental(ExperimentalCoroutinesApi::class)
class DetailViewModelTest : BaseTestWithMainThread() { class DetailViewModelTest : BaseTestWithMainThread() {
private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true) private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true)
private val getLaunchDetailsParams = GetLaunchDetails.Params(1013)
@ExperimentalCoroutinesApi private val viewModel = spyk(DetailViewModel(getLaunchDetails, getLaunchDetailsParams))
private val viewModel = spyk(DetailViewModel(getLaunchDetails))
@Test @Test
@ExperimentalCoroutinesApi
fun `loadData should invoke getLauchDetails with provided params`() { fun `loadData should invoke getLauchDetails with provided params`() {
dispatcher.runBlockingTest { dispatcher.runBlockingTest {
val paramsSlot = slot<GetLaunchDetails.Params>() val paramsSlot = slot<GetLaunchDetails.Params>()
viewModel.createParamsFor(1013)
viewModel.loadData() viewModel.loadData()
// init should have called it already due to creation above // init should have called it already due to creation above

View File

@@ -7,8 +7,7 @@ apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi implementation libraries.paging
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest testImplementation testLibraries.coroutinesTest
} }

Binary file not shown.

View File

@@ -2,6 +2,7 @@ package com.melih.list.di
import com.melih.list.di.modules.LaunchesBinds import com.melih.list.di.modules.LaunchesBinds
import com.melih.list.di.modules.LaunchesProvides import com.melih.list.di.modules.LaunchesProvides
import com.melih.list.di.scopes.LaunchesFragmentScope
import com.melih.list.ui.LaunchesFragment import com.melih.list.ui.LaunchesFragment
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
@@ -20,6 +21,7 @@ abstract class LaunchesContributor {
LaunchesBinds::class LaunchesBinds::class
] ]
) )
abstract fun listFragment(): LaunchesFragment @LaunchesFragmentScope
abstract fun launchesFragment(): LaunchesFragment
// endregion // endregion
} }

View File

@@ -0,0 +1,24 @@
package com.melih.list.di
import com.melih.list.di.scopes.LaunchesScope
import com.melih.list.ui.LaunchesActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
/**
* Contributes fragments & view models in this module
*/
@Module
abstract class LaunchesFeatureModule {
// region Contributes
@ContributesAndroidInjector(
modules = [
LaunchesContributor::class
]
)
@LaunchesScope
abstract fun launchesActivity(): LaunchesActivity
// endregion
}

View File

@@ -2,11 +2,10 @@ package com.melih.list.di.modules
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.melih.core.di.keys.ViewModelKey import com.melih.core.di.keys.ViewModelKey
import com.melih.list.ui.LaunchesViewModel import com.melih.list.ui.vm.LaunchesViewModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import kotlinx.coroutines.ExperimentalCoroutinesApi
@Module @Module
abstract class LaunchesBinds { abstract class LaunchesBinds {
@@ -16,7 +15,6 @@ abstract class LaunchesBinds {
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(LaunchesViewModel::class) @ViewModelKey(LaunchesViewModel::class)
@ExperimentalCoroutinesApi
abstract fun listViewModel(listViewModel: LaunchesViewModel): ViewModel abstract fun listViewModel(listViewModel: LaunchesViewModel): ViewModel
// endregion // endregion
} }

View File

@@ -0,0 +1,24 @@
package com.melih.list.di.modules
import androidx.paging.Config
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.GetLaunches
import dagger.Module
import dagger.Provides
@Module
class LaunchesProvides {
/**
* Provides lauches, using default value of 15
*/
@Provides
fun provideGetLaunchesParams() = GetLaunches.Params(page = 0)
@Provides
fun getPagingConfig() = Config(
DEFAULT_LAUNCHES_AMOUNT,
prefetchDistance = 2,
enablePlaceholders = false
)
}

View File

@@ -0,0 +1,7 @@
package com.melih.list.di.scopes
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class LaunchesFragmentScope

View File

@@ -0,0 +1,7 @@
package com.melih.list.di.scopes
import javax.inject.Scope
@Scope
@Retention(AnnotationRetention.BINARY)
annotation class LaunchesScope

View File

@@ -5,13 +5,11 @@ import androidx.navigation.fragment.NavHostFragment
import com.melih.core.base.lifecycle.BaseActivity import com.melih.core.base.lifecycle.BaseActivity
import com.melih.list.R import com.melih.list.R
import com.melih.list.databinding.LaunchesActivityBinding import com.melih.list.databinding.LaunchesActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
class LaunchesActivity : BaseActivity<LaunchesActivityBinding>() { class LaunchesActivity : BaseActivity<LaunchesActivityBinding>() {
// region Functions // region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)

View File

@@ -0,0 +1,97 @@
package com.melih.list.ui
import android.os.Bundle
import android.view.View
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.melih.core.actions.Actions
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe
import com.melih.list.R
import com.melih.list.databinding.ListBinding
import com.melih.list.ui.adapters.LaunchesAdapter
import com.melih.list.ui.vm.LaunchesViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.State
class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.OnRefreshListener {
// region Properties
private val viewModel: LaunchesViewModel
get() = viewModelFactory.createFor(this)
private val launchesAdapter = LaunchesAdapter(::onItemSelected)
// endregion
// region Functions
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//setHasOptionsMenu(true)
binding.rocketList.adapter = launchesAdapter
binding.swipeRefreshLayout.setOnRefreshListener(this)
observeDataChanges()
}
//override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// inflater.inflate(R.menu.menu_rocket_list, menu)
//
// with(menu.findItem(R.id.search)) {
// onExpandOrCollapse(::onSearchExpand, ::onSearchCollapse)
// setSearchQueryListener(actionView as SearchView)
// }
//
// super.onCreateOptionsMenu(menu, inflater)
//}
private fun observeDataChanges() {
// Observing state to show loading
observe(viewModel.stateData) {
binding.swipeRefreshLayout.isRefreshing = it is State.Loading
}
// Observing error to show toast with retry action
observe(viewModel.errorData) {
showSnackbarWithAction(it) {
viewModel.retry()
}
}
observe(viewModel.pagedList) {
launchesAdapter.submitList(it)
}
//observe(viewModel.filteredItems) {
// launchesAdapter.submitList(it)
//}
}
private fun onItemSelected(item: LaunchEntity?) {
startActivity(Actions.openDetailFor(item?.id ?: -1L))
}
//private fun onSearchExpand() {
// binding.swipeRefreshLayout.isEnabled = false
//}
//private fun onSearchCollapse() {
// binding.swipeRefreshLayout.isEnabled = true
//}
//private fun setSearchQueryListener(searchView: SearchView) {
// searchView.setOnQueryChangedListener {
// viewModel.filterItemListBy(it)
// }
//}
override fun onRefresh() {
viewModel.refresh()
}
override fun getLayoutId(): Int = R.layout.fragment_launches
// endregion
}

View File

@@ -1,14 +1,14 @@
package com.melih.list.ui package com.melih.list.ui.adapters
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.melih.core.base.recycler.BaseListAdapter import com.melih.core.base.recycler.BasePagingListAdapter
import com.melih.core.base.recycler.BaseViewHolder import com.melih.core.base.recycler.BaseViewHolder
import com.melih.list.databinding.LaunchRowBinding import com.melih.list.databinding.LaunchRowBinding
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BaseListAdapter<LaunchEntity>( class LaunchesAdapter(itemClickListener: (LaunchEntity?) -> Unit) : BasePagingListAdapter<LaunchEntity>(
object : DiffUtil.ItemCallback<LaunchEntity>() { object : DiffUtil.ItemCallback<LaunchEntity>() {
override fun areItemsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean = override fun areItemsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
@@ -30,11 +30,11 @@ class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BaseListAdapt
class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) { class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) {
override fun bind(item: LaunchEntity) { override fun bind(item: LaunchEntity?) {
binding.entity = item binding.entity = item
val missions = item.missions val missions = item?.missions
binding.tvDescription.text = if (missions.isNotEmpty()) missions[0].description else "" binding.tvDescription.text = if (!missions.isNullOrEmpty()) missions[0].description else ""
binding.executePendingBindings() binding.executePendingBindings()
} }

View File

@@ -0,0 +1,26 @@
package com.melih.list.ui.paging
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunches
import com.melih.repository.interactors.base.Result
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class LaunchesPagingSource @Inject constructor(
private val getLaunches: GetLaunches,
private val getLaunchesParams: GetLaunches.Params
) : BasePagingDataSource<LaunchEntity>() {
//region Functions
@UseExperimental(ExperimentalCoroutinesApi::class)
override fun loadDataForPage(page: Int): Flow<Result<List<LaunchEntity>>> =
getLaunches(
getLaunchesParams.copy(
page = page
)
)
//endregion
}

View File

@@ -0,0 +1,14 @@
package com.melih.list.ui.paging
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.entities.LaunchEntity
import javax.inject.Inject
import javax.inject.Provider
class LaunchesPagingSourceFactory @Inject constructor(
private val sourceProvider: Provider<LaunchesPagingSource>
) : BasePagingFactory<LaunchEntity>() {
override fun createSource(): BasePagingDataSource<LaunchEntity> = sourceProvider.get()
}

View File

@@ -0,0 +1,45 @@
package com.melih.list.ui.vm
import androidx.paging.PagedList
import com.melih.core.base.paging.BasePagingFactory
import com.melih.core.base.viewmodel.BasePagingViewModel
import com.melih.list.ui.paging.LaunchesPagingSourceFactory
import com.melih.repository.entities.LaunchEntity
import javax.inject.Inject
class LaunchesViewModel @Inject constructor(
private val launchesPagingSourceFactory: LaunchesPagingSourceFactory,
private val launchesPagingConfig: PagedList.Config
) : BasePagingViewModel<LaunchEntity>() {
// region Properties
override val factory: BasePagingFactory<LaunchEntity>
get() = launchesPagingSourceFactory
override val config: PagedList.Config
get() = launchesPagingConfig
//private val _filteredItems = MediatorLiveData<PagedList<LaunchEntity>>()
//val filteredItems: LiveData<PagedList<LaunchEntity>>
// get() = _filteredItems
// endregion
//init {
// _filteredItems.addSource(pagedList, _filteredItems::setValue)
//}
// region Functions
//fun filterItemListBy(query: String?) {
//
// _filteredItems.value = if (!query.isNullOrBlank()) {
// pagedList.value
// ?.snapshot() as PagedList<LaunchEntity>
// } else {
// pagedList.value
// }
//}
// endregion
}

View File

@@ -8,7 +8,7 @@
<variable <variable
name="viewModel" name="viewModel"
type="com.melih.list.ui.LaunchesViewModel" /> type="com.melih.list.ui.vm.LaunchesViewModel" />
</data> </data>
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout

View File

@@ -65,7 +65,7 @@
android:layout_marginLeft="@dimen/padding_standard" android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard" android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard" android:layout_marginRight="@dimen/padding_standard"
android:text="@{entity.rocket.name}" android:text="@{entity.name}"
android:textAppearance="@style/TitleTextAppearance" android:textAppearance="@style/TitleTextAppearance"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket" app:layout_constraintStart_toEndOf="@+id/imgRocket"

View File

@@ -1,3 +1,5 @@
@file:UseExperimental(ExperimentalCoroutinesApi::class)
package com.melih.list package com.melih.list
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -10,17 +12,14 @@ import org.junit.jupiter.api.BeforeEach
abstract class BaseTestWithMainThread { abstract class BaseTestWithMainThread {
@ExperimentalCoroutinesApi
protected val dispatcher = TestCoroutineDispatcher() protected val dispatcher = TestCoroutineDispatcher()
@BeforeEach @BeforeEach
@ExperimentalCoroutinesApi
fun setUp() { fun setUp() {
Dispatchers.setMain(dispatcher) Dispatchers.setMain(dispatcher)
} }
@AfterEach @AfterEach
@ExperimentalCoroutinesApi
fun tearDown() { fun tearDown() {
Dispatchers.resetMain() Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines() dispatcher.cleanupTestCoroutines()

View File

@@ -1,16 +0,0 @@
package com.melih.list.di.modules
import com.melih.list.ui.LaunchesAdapter
import com.melih.repository.interactors.GetLaunches
import dagger.Module
import dagger.Provides
@Module
class LaunchesProvides {
/**
* Provides lauches, using default value of 10
*/
@Provides
fun provideGetLaunchesParams() = GetLaunches.Params()
}

View File

@@ -1,100 +0,0 @@
package com.melih.list.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.melih.core.actions.Actions
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.containsIgnoreCase
import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe
import com.melih.core.extensions.setOnQueryChangedListener
import com.melih.list.R
import com.melih.list.databinding.ListBinding
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
import kotlinx.coroutines.ExperimentalCoroutinesApi
class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.OnRefreshListener {
// region Properties
@ExperimentalCoroutinesApi
private val viewModel: LaunchesViewModel
get() = viewModelFactory.createFor(this)
private val launchesAdapter = LaunchesAdapter(::onItemSelected)
private val itemList = mutableListOf<LaunchEntity>()
// endregion
// region Functions
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
binding.rocketList.adapter = launchesAdapter
binding.swipeRefreshLayout.setOnRefreshListener(this)
observeDataChanges()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_rocket_list, menu)
setSearchQueryListener((menu.findItem(R.id.search).actionView as SearchView))
super.onCreateOptionsMenu(menu, inflater)
}
@ExperimentalCoroutinesApi
private fun observeDataChanges() {
// Observing state to show loading
observe(viewModel.stateData) {
binding.swipeRefreshLayout.isRefreshing = it is Result.State.Loading
}
// Observing error to show toast with retry action
observe(viewModel.errorData) {
showSnackbarWithAction(it) {
viewModel.retry()
}
}
observe(viewModel.successData) {
itemList.addAll(it)
launchesAdapter.submitList(itemList)
binding.rocketList.scheduleLayoutAnimation()
}
}
private fun onItemSelected(item: LaunchEntity) {
startActivity(Actions.openDetailFor(item.id))
}
private fun setSearchQueryListener(searchView: SearchView) {
searchView.setOnQueryChangedListener {
filterItemListBy(it)
}
}
private fun filterItemListBy(query: String?) =
if (!query.isNullOrBlank()) {
itemList.filter {
it.rocket.name.containsIgnoreCase(query) || it.missions.any { it.description.containsIgnoreCase(query) }
}
} else {
itemList
}
@ExperimentalCoroutinesApi
override fun onRefresh() {
viewModel.refresh()
}
override fun getLayoutId(): Int = R.layout.fragment_launches
// endregion
}

View File

@@ -1,38 +0,0 @@
package com.melih.list.ui
import androidx.lifecycle.viewModelScope
import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunches
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@ExperimentalCoroutinesApi
class LaunchesViewModel @Inject constructor(
private val getLaunches: GetLaunches,
private val getLaunchesParams: GetLaunches.Params
) : BaseViewModel<List<LaunchEntity>>() {
// region Initialization
init {
loadData()
}
// endregion
// region Functions
/**
* Triggering interactor in view model scope
*/
override fun loadData() {
viewModelScope.launch {
getLaunches(getLaunchesParams).collect {
it.handle(::handleState, ::handleFailure, ::handleSuccess)
}
}
}
// endregion
}

View File

@@ -1,33 +0,0 @@
package com.melih.list
import com.melih.list.ui.LaunchesViewModel
import com.melih.repository.interactors.GetLaunches
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class LaunchesViewModelTest : BaseTestWithMainThread() {
val getLaunches: GetLaunches = mockk(relaxed = true)
val getLaunchesParams: GetLaunches.Params = mockk(relaxed = true)
@Test
@ExperimentalCoroutinesApi
fun `loadData should invoke getLauches with provided params`() {
spyk(LaunchesViewModel(getLaunches, getLaunchesParams))
dispatcher.runBlockingTest {
// init should have called it already due to creation above
verify(exactly = 1) { getLaunches(getLaunchesParams) }
}
}
}

View File

@@ -25,6 +25,7 @@ dependencies {
implementation libraries.okHttpLogger implementation libraries.okHttpLogger
kapt annotationProcessors.roomCompiler kapt annotationProcessors.roomCompiler
kapt annotationProcessors.moshi
testImplementation testLibraries.coroutinesCore testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest testImplementation testLibraries.coroutinesTest

View File

@@ -8,6 +8,6 @@ import com.melih.repository.interactors.base.Result
*/ */
abstract class Repository { abstract class Repository {
internal abstract suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> internal abstract suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity> internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity>
} }

View File

@@ -4,8 +4,10 @@ import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.melih.repository.DEFAULT_NAME import com.melih.repository.DEFAULT_NAME
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Entity(tableName = "Launches") @Entity(tableName = "Launches")
@JsonClass(generateAdapter = true)
data class LaunchEntity( data class LaunchEntity(
@PrimaryKey val id: Long = 0L, @PrimaryKey val id: Long = 0L,
val name: String = DEFAULT_NAME, val name: String = DEFAULT_NAME,

View File

@@ -1,5 +1,8 @@
package com.melih.repository.entities package com.melih.repository.entities
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LaunchesEntity( data class LaunchesEntity(
val id: Long = 0L, val id: Long = 0L,
val launches: List<LaunchEntity> = listOf(), val launches: List<LaunchEntity> = listOf(),

View File

@@ -2,13 +2,16 @@ package com.melih.repository.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.repository.DEFAULT_NAME
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LocationEntity( data class LocationEntity(
@ColumnInfo(name = "id_location") val id: Long = 0L, @ColumnInfo(name = "id_location") val id: Long = 0L,
@ColumnInfo(name = "name_location") val name: String = DEFAULT_NAME, @ColumnInfo(name = "name_location") val name: String = DEFAULT_NAME,
val pads: List<PadEntity> = listOf(PadEntity()) val pads: List<PadEntity> = listOf(PadEntity())
) )
@JsonClass(generateAdapter = true)
data class PadEntity( data class PadEntity(
@ColumnInfo(name = "id_pad") val id: Long = 0L, @ColumnInfo(name = "id_pad") val id: Long = 0L,
@ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME, @ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME,

View File

@@ -3,7 +3,9 @@ package com.melih.repository.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING import com.melih.repository.EMPTY_STRING
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MissionEntity( data class MissionEntity(
@ColumnInfo(name = "id_mission") val id: Long = 0L, @ColumnInfo(name = "id_mission") val id: Long = 0L,
@ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME, @ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME,

View File

@@ -4,7 +4,9 @@ import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING import com.melih.repository.EMPTY_STRING
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RocketEntity( data class RocketEntity(
@ColumnInfo(name = "id_rocket") val id: Long = 0L, @ColumnInfo(name = "id_rocket") val id: Long = 0L,
@ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME, @ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME,

View File

@@ -2,9 +2,12 @@ package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.InteractorParameters import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.Result
import com.melih.repository.sources.SourceManager import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject import javax.inject.Inject
@@ -12,13 +15,32 @@ import javax.inject.Inject
/** /**
* Gets next given number of launches * Gets next given number of launches
*/ */
class GetLaunchDetails @Inject constructor( @UseExperimental(ExperimentalCoroutinesApi::class)
private val sourceManager: SourceManager class GetLaunchDetails @Inject constructor() : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
) : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
@ExperimentalCoroutinesApi @field:Inject
override suspend fun run(collector: FlowCollector<Result<LaunchEntity>>, params: Params) { internal lateinit var networkSource: NetworkSource
collector.emit(sourceManager.getLaunchById(params.id))
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
override suspend fun FlowCollector<Result<LaunchEntity>>.run(params: Params) {
val result = persistenceSource.getLaunchById(params.id)
if (result !is Success) {
when (val response = networkSource.getLaunchById(params.id)) {
// Save result and return again from persistence
is Success -> {
persistenceSource.saveLaunch(response.successData)
emit(persistenceSource.getLaunchById(params.id))
}
// Redirect failure as it is
is Failure -> emit(response)
}
} else {
emit(result)
}
} }
data class Params( data class Params(

View File

@@ -4,24 +4,45 @@ import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.InteractorParameters import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.Result
import com.melih.repository.sources.SourceManager import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/** /**
* Gets next given number of launches * Gets next given number of launches
*/ */
class GetLaunches @Inject constructor( @UseExperimental(ExperimentalCoroutinesApi::class)
private val sourceManager: SourceManager class GetLaunches @Inject constructor() : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
) : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
@ExperimentalCoroutinesApi @field:Inject
override suspend fun run(collector: FlowCollector<Result<List<LaunchEntity>>>, params: Params) { internal lateinit var networkSource: NetworkSource
collector.emit(sourceManager.getNextLaunches(params.count))
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
override suspend fun FlowCollector<Result<List<LaunchEntity>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
networkSource
.getNextLaunches(params.count, params.page)
.also {
if (it is Success) {
persistenceSource.saveLaunches(it.successData)
emit(persistenceSource.getNextLaunches(params.count, params.page))
} else {
emit(it)
emit(persistenceSource.getNextLaunches(params.count, params.page))
}
}
} }
data class Params( data class Params(
val count: Int = 10 val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters ) : InteractorParameters
} }

View File

@@ -10,23 +10,22 @@ import kotlinx.coroutines.flow.flowOn
/** /**
* Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage. * Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage.
*/ */
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BaseInteractor<T, in P : InteractorParameters> { abstract class BaseInteractor<T, in P : InteractorParameters> {
// region Abstractions // region Abstractions
@ExperimentalCoroutinesApi protected abstract suspend fun FlowCollector<Result<T>>.run(params: P)
protected abstract suspend fun run(collector: FlowCollector<Result<T>>, params: P)
// endregion // endregion
// region Functions // region Functions
@ExperimentalCoroutinesApi
operator fun invoke(params: P) = operator fun invoke(params: P) =
flow<Result<T>> { flow<Result<T>> {
emit(Result.State.Loading()) emit(State.Loading())
run(this, params) run(params)
emit(Result.State.Loaded()) emit(State.Loaded())
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
// endregion // endregion
} }

View File

@@ -5,15 +5,14 @@ import com.melih.repository.R
/** /**
* [Result.Failure] reasons * [Failure] reasons
*/ */
sealed class Reason(@StringRes val messageRes: Int) { sealed class Reason(@StringRes val messageRes: Int)
class NetworkError : Reason(R.string.reason_network) class NetworkError : Reason(R.string.reason_network)
class EmptyResultError : Reason(R.string.reason_empty_body) class EmptyResultError : Reason(R.string.reason_empty_body)
class GenericError : Reason(R.string.reason_generic) class GenericError : Reason(R.string.reason_generic)
class ResponseError : Reason(R.string.reason_response) class ResponseError : Reason(R.string.reason_response)
class TimeoutError : Reason(R.string.reason_timeout) class TimeoutError : Reason(R.string.reason_timeout)
class PersistenceEmpty : Reason(R.string.reason_persistance_empty) class PersistenceEmpty : Reason(R.string.reason_persistance_empty)
class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty) class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty)
}

View File

@@ -1,45 +1,53 @@
package com.melih.repository.interactors.base package com.melih.repository.interactors.base
import kotlinx.coroutines.ExperimentalCoroutinesApi
/** /**
* Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor] * Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor]
*/ */
sealed class Result<out T> { @UseExperimental(ExperimentalCoroutinesApi::class)
sealed class Result<out T>
// region Subclasses // region Subclasses
class Success<out T>(val successData: T) : Result<T>() class Success<out T>(val successData: T) : Result<T>()
class Failure(val errorData: Reason) : Result<Nothing>() class Failure(val errorData: Reason) : Result<Nothing>()
sealed class State : Result<Nothing>() { sealed class State : Result<Nothing>() {
class Loading : State() class Loading : State()
class Loaded : State() class Loaded : State()
}
// endregion
// region Functions
inline fun handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) {
when (this) {
is Success -> successBlock(successData)
is Failure -> failureBlock(errorData)
is State -> stateBlock(this)
}
}
inline fun handleSuccess(successBlock: (T) -> Unit) {
if (this is Success)
successBlock(successData)
}
inline fun handleFailure(errorBlock: (Reason) -> Unit) {
if (this is Failure)
errorBlock(errorData)
}
inline fun handleState(stateBlock: (State) -> Unit) {
if (this is State)
stateBlock(this)
}
// endregion
} }
// endregion
// region Extensions
inline fun <T> Result<T>.handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) {
when (this) {
is Success -> successBlock(successData)
is Failure -> failureBlock(errorData)
is State -> stateBlock(this)
}
}
inline fun <T> Result<T>.onSuccess(successBlock: (T) -> Unit): Result<T> {
if (this is Success)
successBlock(successData)
return this
}
inline fun <T> Result<T>.onFailure(errorBlock: (Reason) -> Unit): Result<T> {
if (this is Failure)
errorBlock(errorData)
return this
}
inline fun <T> Result<T>.onState(stateBlock: (State) -> Unit): Result<T> {
if (this is State)
stateBlock(this)
return this
}
// endregion

View File

@@ -5,15 +5,21 @@ import com.melih.repository.entities.LaunchesEntity
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
/** /**
* Retrofit interface for networking * Retrofit interface for networking
*/ */
interface Api { internal interface Api {
@GET("launch/next/{count}") @GET("launch/next/{count}")
suspend fun getNextLaunches(@Path("count") count: Int): Response<LaunchesEntity> suspend fun getNextLaunches(
@Path("count") count: Int,
@Query("offset") offset: Int
): Response<LaunchesEntity>
@GET("launch/{id}") @GET("launch/{id}")
suspend fun getLaunchById(@Path("id") id: Long): Response<LaunchEntity> suspend fun getLaunchById(
@Path("id") id: Long
): Response<LaunchEntity>
} }

View File

@@ -9,9 +9,12 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response import retrofit2.Response
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
class ApiImpl @Inject constructor() : Api { internal const val TIMEOUT_DURATION = 7L
internal class ApiImpl @Inject constructor() : Api {
// region Properties // region Properties
@@ -23,9 +26,12 @@ class ApiImpl @Inject constructor() : Api {
Retrofit.Builder() Retrofit.Builder()
.client( .client(
OkHttpClient.Builder() OkHttpClient.Builder()
.connectTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor() HttpLoggingInterceptor().apply {
.setLevel(HttpLoggingInterceptor.Level.BODY) level = HttpLoggingInterceptor.Level.BODY
}
).build() ).build()
) )
.addConverterFactory(MoshiConverterFactory.create(moshi)) .addConverterFactory(MoshiConverterFactory.create(moshi))
@@ -35,9 +41,14 @@ class ApiImpl @Inject constructor() : Api {
} }
// endregion // endregion
override suspend fun getNextLaunches(count: Int): Response<LaunchesEntity> = override suspend fun getNextLaunches(
service.getNextLaunches(count) count: Int,
offset: Int
): Response<LaunchesEntity> =
service.getNextLaunches(count, offset)
override suspend fun getLaunchById(id: Long): Response<LaunchEntity> = override suspend fun getLaunchById(
id: Long
): Response<LaunchEntity> =
service.getLaunchById(id) service.getLaunchById(id)
} }

View File

@@ -1,6 +1,8 @@
package com.melih.repository.persistence package com.melih.repository.persistence
import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
@@ -24,7 +26,14 @@ const val DB_NAME = "LaunchesDB"
RocketConverter::class, RocketConverter::class,
MissionConverter::class MissionConverter::class
) )
abstract class LaunchesDatabase : RoomDatabase() { internal abstract class LaunchesDatabase : RoomDatabase() {
abstract val launchesDao: LaunchesDao companion object {
fun getInstance(ctx: Context) =
Room.databaseBuilder(ctx, LaunchesDatabase::class.java, DB_NAME)
.build()
}
internal abstract val launchesDao: LaunchesDao
} }

View File

@@ -1,6 +1,7 @@
package com.melih.repository.persistence.converters package com.melih.repository.persistence.converters
import com.melih.repository.entities.LocationEntity import com.melih.repository.entities.LocationEntity
import com.melih.repository.entities.LocationEntityJsonAdapter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
@@ -9,5 +10,5 @@ import com.squareup.moshi.Moshi
*/ */
class LocationConverter : BaseConverter<LocationEntity>() { class LocationConverter : BaseConverter<LocationEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<LocationEntity> = override fun getAdapter(moshi: Moshi): JsonAdapter<LocationEntity> =
moshi.adapter(LocationEntity::class.java) LocationEntityJsonAdapter(moshi)
} }

View File

@@ -1,6 +1,7 @@
package com.melih.repository.persistence.converters package com.melih.repository.persistence.converters
import com.melih.repository.entities.RocketEntity import com.melih.repository.entities.RocketEntity
import com.melih.repository.entities.RocketEntityJsonAdapter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
@@ -9,5 +10,5 @@ import com.squareup.moshi.Moshi
*/ */
class RocketConverter : BaseConverter<RocketEntity>() { class RocketConverter : BaseConverter<RocketEntity>() {
override fun getAdapter(moshi: Moshi): JsonAdapter<RocketEntity> = override fun getAdapter(moshi: Moshi): JsonAdapter<RocketEntity> =
moshi.adapter(RocketEntity::class.java) RocketEntityJsonAdapter(moshi)
} }

View File

@@ -2,20 +2,20 @@ package com.melih.repository.persistence.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
/** /**
* DAO for list of [launches][LaunchEntity] * DAO for list of [launches][LaunchEntity]
*/ */
@Dao @Dao
abstract class LaunchesDao { internal abstract class LaunchesDao {
// region Queries // region Queries
@Query("SELECT * FROM Launches LIMIT :count") @Query("SELECT * FROM Launches ORDER BY launchStartTime DESC LIMIT :count OFFSET :page*:count")
abstract suspend fun getLaunches(count: Int): List<LaunchEntity> abstract suspend fun getLaunches(count: Int, page: Int): List<LaunchEntity>
@Query("SELECT * FROM Launches WHERE id=:id LIMIT 1") @Query("SELECT * FROM Launches WHERE id=:id LIMIT 1")
abstract suspend fun getLaunchById(id: Long): LaunchEntity? abstract suspend fun getLaunchById(id: Long): LaunchEntity?
@@ -26,19 +26,10 @@ abstract class LaunchesDao {
// region Insertion // region Insertion
@Insert @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveLaunches(launches: List<LaunchEntity>) abstract suspend fun saveLaunches(launches: List<LaunchEntity>)
@Insert @Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun saveLaunch(launch: LaunchEntity) abstract suspend fun saveLaunch(launch: LaunchEntity)
// endregion // endregion
// region Transactions
@Transaction
open suspend fun updateLaunches(launches: List<LaunchEntity>) {
nukeLaunches()
saveLaunches(launches)
}
// endregion
} }

View File

@@ -4,8 +4,15 @@ import android.net.NetworkInfo
import com.melih.repository.DEFAULT_IMAGE_SIZE import com.melih.repository.DEFAULT_IMAGE_SIZE
import com.melih.repository.Repository import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.TimeoutError
import com.melih.repository.network.ApiImpl import com.melih.repository.network.ApiImpl
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -14,9 +21,9 @@ import javax.inject.Provider
/** /**
* NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository], * NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository],
* returning either [failure][Result.Failure] with proper [reason][Reason] or [success][Result.Success] with data * returning either [failure][Failure] with proper [reason][Reason] or [success][Success] with data
*/ */
class NetworkSource @Inject constructor( internal class NetworkSource @Inject constructor(
private val apiImpl: ApiImpl, private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo> private val networkInfoProvider: Provider<NetworkInfo>
) : Repository() { ) : Repository() {
@@ -31,8 +38,10 @@ class NetworkSource @Inject constructor(
// region Functions // region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> = override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
safeExecute(apiImpl::getNextLaunches, count) { entity -> safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches.map { launch -> entity.launches.map { launch ->
if (!launch.rocket.imageURL.isNotBlank()) { if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy( launch.copy(
@@ -50,7 +59,9 @@ class NetworkSource @Inject constructor(
} }
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> = override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
safeExecute(apiImpl::getLaunchById, id) { safeExecute({
apiImpl.getLaunchById(id)
}) {
if (!it.rocket.imageURL.isNotBlank()) { if (!it.rocket.imageURL.isNotBlank()) {
it.copy( it.copy(
rocket = it.rocket.copy( rocket = it.rocket.copy(
@@ -62,28 +73,27 @@ class NetworkSource @Inject constructor(
} }
} }
private suspend inline fun <T, P, R> safeExecute( private suspend inline fun <T, R> safeExecute(
block: suspend (param: P) -> Response<T>, block: suspend () -> Response<T>,
param: P,
transform: (T) -> R transform: (T) -> R
) = ) =
if (isNetworkConnected) { if (isNetworkConnected) {
try { try {
block(param).extractResponseBody(transform) block().extractResponseBody(transform)
} catch (e: IOException) { } catch (e: IOException) {
Result.Failure(Reason.TimeoutError()) Failure(TimeoutError())
} }
} else { } else {
Result.Failure(Reason.NetworkError()) Failure(NetworkError())
} }
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) = private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) { if (isSuccessful) {
body()?.let { body()?.let {
Result.Success(transform(it)) Success(transform(it))
} ?: Result.Failure(Reason.EmptyResultError()) } ?: Failure(EmptyResultError())
} else { } else {
Result.Failure(Reason.ResponseError()) Failure(ResponseError())
} }
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) = private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =

View File

@@ -1,28 +1,34 @@
package com.melih.repository.sources package com.melih.repository.sources
import android.content.Context
import com.melih.repository.Repository import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.persistence.LaunchesDatabase import com.melih.repository.persistence.LaunchesDatabase
import javax.inject.Inject import javax.inject.Inject
/** /**
* Persistance source using Room database to save / read objects for SST - offline usage * Persistance source using Room database to save / read objects for SST - offline usage
*/ */
class PersistenceSource @Inject constructor( internal class PersistenceSource @Inject constructor(
private val launchesDatabase: LaunchesDatabase ctx: Context
) : Repository() { ) : Repository() {
// region Functions // region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> = private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
launchesDatabase launchesDatabase
.launchesDao .launchesDao
.getLaunches(count) .getLaunches(count, page)
.takeIf { it.isNotEmpty() } .takeIf { it.isNotEmpty() }
?.run { ?.run {
Result.Success(this) Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty()) } ?: Failure(PersistenceEmpty())
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> = override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
launchesDatabase launchesDatabase
@@ -30,11 +36,11 @@ class PersistenceSource @Inject constructor(
.getLaunchById(id) .getLaunchById(id)
.takeIf { it != null } .takeIf { it != null }
?.run { ?.run {
Result.Success(this) Success(this)
} ?: Result.Failure(Reason.PersistenceEmpty()) } ?: Failure(PersistenceEmpty())
internal suspend fun saveLaunches(launches: List<LaunchEntity>) { internal suspend fun saveLaunches(launches: List<LaunchEntity>) {
launchesDatabase.launchesDao.updateLaunches(launches) launchesDatabase.launchesDao.saveLaunches(launches)
} }
internal suspend fun saveLaunch(launch: LaunchEntity) { internal suspend fun saveLaunch(launch: LaunchEntity) {

View File

@@ -1,55 +0,0 @@
package com.melih.repository.sources
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import javax.inject.Inject
/**
* Manages SST by using network & persistance sources
*/
class SourceManager @Inject constructor(
private val networkSource: NetworkSource,
private val persistenceSource: PersistenceSource
) : Repository() {
// region Functions
override suspend fun getNextLaunches(count: Int): Result<List<LaunchEntity>> {
networkSource
.getNextLaunches(count)
.takeIf { it is Result.Success }
?.let {
persistenceSource.saveLaunches((it as Result.Success).successData)
}
return persistenceSource
.getNextLaunches(count)
.takeIf {
it is Result.Success && it.successData.isNotEmpty()
}
?: Result.Failure(Reason.NoNetworkPersistenceEmpty())
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> {
val result =
persistenceSource
.getLaunchById(id)
return if (result is Result.Failure) {
networkSource
.getLaunchById(id)
.takeIf { it is Result.Success }
?.let {
persistenceSource.saveLaunch((it as Result.Success).successData)
}
persistenceSource
.getLaunchById(id)
} else {
result
}
}
// endregion
}

View File

@@ -12,14 +12,13 @@ import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.* import java.util.*
@UseExperimental(ExperimentalCoroutinesApi::class)
class BaseInteractorTest { class BaseInteractorTest {
val testInteractor = spyk(TestInteractor()) val testInteractor = spyk(TestInteractor())
val testParams = TestParams() val testParams = TestParams()
@Test @Test
@ExperimentalCoroutinesApi
fun `BaseInteractor should send states and items emmited by run`() { fun `BaseInteractor should send states and items emmited by run`() {
// Using run blocking due to threading problems in runBlockingTest // Using run blocking due to threading problems in runBlockingTest
// See https://github.com/Kotlin/kotlinx.coroutines/issues/1204 // See https://github.com/Kotlin/kotlinx.coroutines/issues/1204
@@ -42,24 +41,23 @@ class BaseInteractorTest {
resultDeque.size shouldEqualTo 3 resultDeque.size shouldEqualTo 3
// Verify first item is Loading state // Verify first item is Loading state
resultDeque.poll() shouldBeInstanceOf Result.State.Loading::class resultDeque.poll() shouldBeInstanceOf State.Loading::class
// Verify second item is Success, with default value we set below in TestParams class // Verify second item is Success, with default value we set below in TestParams class
resultDeque.poll().also { resultDeque.poll().also {
it shouldBeInstanceOf Result.Success::class it shouldBeInstanceOf Success::class
(it as Result.Success<Int>).successData shouldEqualTo 10 (it as Success<Int>).successData shouldEqualTo 10
} }
// Verify last item is Loaded state // Verify last item is Loaded state
resultDeque.poll() shouldBeInstanceOf Result.State.Loaded::class resultDeque.poll() shouldBeInstanceOf State.Loaded::class
} }
} }
inner class TestInteractor : BaseInteractor<Int, TestParams>() { inner class TestInteractor : BaseInteractor<Int, TestParams>() {
@ExperimentalCoroutinesApi override suspend fun FlowCollector<Result<Int>>.run(params: TestParams) {
override suspend fun run(collector: FlowCollector<Result<Int>>, params: TestParams) { emit(Success(params.testValue))
collector.emit(Result.Success(params.testValue))
} }
} }

View File

@@ -12,11 +12,11 @@ class ResultTest {
private val number = 10 private val number = 10
private val success = Result.Success(number) private val success = Success(number)
private val failure = Result.Failure(Reason.GenericError()) private val failure = Failure(GenericError())
private val state = Result.State.Loading() private val state = State.Loading()
private val emptyStateBlock = spyk({ _: Result.State -> }) private val emptyStateBlock = spyk({ _: State -> })
private val emptyFailureBlock = spyk({ _: Reason -> }) private val emptyFailureBlock = spyk({ _: Reason -> })
private val emptySuccessBlock = spyk({ _: Int -> }) private val emptySuccessBlock = spyk({ _: Int -> })
@@ -37,8 +37,8 @@ class ResultTest {
@Test @Test
fun `Failure should only invoke failureBlock with correct error`() { fun `Failure should only invoke failureBlock with correct error`() {
val actualFailureBlock = spyk({ reason: Reason -> val actualFailureBlock = spyk({ reason: Reason ->
reason shouldBeInstanceOf Reason.GenericError::class reason shouldBeInstanceOf GenericError::class
(reason as Reason.GenericError).messageRes shouldEqualTo R.string.reason_generic (reason as GenericError).messageRes shouldEqualTo R.string.reason_generic
Unit Unit
}) })
@@ -51,8 +51,8 @@ class ResultTest {
@Test @Test
fun `State should only invoke stateBlock with correct state`() { fun `State should only invoke stateBlock with correct state`() {
val actualSuccessBlock = spyk({ state: Result.State -> val actualSuccessBlock = spyk({ state: State ->
state shouldBeInstanceOf Result.State.Loading::class state shouldBeInstanceOf State.Loading::class
Unit Unit
}) })

View File

@@ -4,8 +4,13 @@ import android.net.NetworkInfo
import com.melih.repository.R import com.melih.repository.R
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity import com.melih.repository.entities.LaunchesEntity
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.network.ApiImpl import com.melih.repository.network.ApiImpl
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@@ -19,6 +24,7 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import javax.inject.Provider import javax.inject.Provider
@UseExperimental(ExperimentalCoroutinesApi::class)
class NetworkSourceTest { class NetworkSourceTest {
private val apiImpl = mockk<ApiImpl>(relaxed = true) private val apiImpl = mockk<ApiImpl>(relaxed = true)
@@ -32,66 +38,62 @@ class NetworkSourceTest {
inner class GetNextLaunches { inner class GetNextLaunches {
@Test @Test
@ExperimentalCoroutinesApi
fun `should return network error when internet is not connected`() { fun `should return network error when internet is not connected`() {
every { networkInfoProvider.get().isConnected } returns false every { networkInfoProvider.get().isConnected } returns false
runBlockingTest { runBlockingTest {
val result = source.getNextLaunches(1) val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class result shouldBeInstanceOf Failure::class
result.handleFailure { result.onFailure {
it shouldBeInstanceOf Reason.NetworkError::class it shouldBeInstanceOf NetworkError::class
} }
} }
} }
@Test @Test
@ExperimentalCoroutinesApi
fun `should return response error when it is not successful`() { fun `should return response error when it is not successful`() {
every { networkInfoProvider.get().isConnected } returns true every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns false coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns false
runBlockingTest { runBlockingTest {
val result = source.getNextLaunches(1) val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class result shouldBeInstanceOf Failure::class
result.handleFailure { result.onFailure {
it shouldBeInstanceOf Reason.ResponseError::class it shouldBeInstanceOf ResponseError::class
(it as Reason.ResponseError).messageRes shouldEqualTo R.string.reason_response (it as ResponseError).messageRes shouldEqualTo R.string.reason_response
} }
} }
} }
@Test @Test
@ExperimentalCoroutinesApi
fun `should return empty result error when body is null`() { fun `should return empty result error when body is null`() {
every { networkInfoProvider.get().isConnected } returns true every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any()).body() } returns null coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns null
runBlockingTest { runBlockingTest {
val result = source.getNextLaunches(1) val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Failure::class result shouldBeInstanceOf Failure::class
result.handleFailure { result.onFailure {
it shouldBeInstanceOf Reason.EmptyResultError::class it shouldBeInstanceOf EmptyResultError::class
} }
} }
} }
@Test @Test
@ExperimentalCoroutinesApi
fun `should return success with data if execution is successful`() { fun `should return success with data if execution is successful`() {
every { networkInfoProvider.get().isConnected } returns true every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013))) coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013)))
runBlockingTest { runBlockingTest {
val result = source.getNextLaunches(1) val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Result.Success::class result shouldBeInstanceOf Success::class
result.handleSuccess { result.onSuccess {
it shouldBeInstanceOf List::class it shouldBeInstanceOf List::class
it.size shouldEqualTo 1 it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013 it[0].id shouldEqualTo 1013

View File

@@ -1,51 +1,68 @@
package com.melih.repository.sources package com.melih.repository.sources
import android.content.Context
import com.melih.repository.entities.LaunchEntity import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.Result import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.persistence.LaunchesDatabase import com.melih.repository.persistence.LaunchesDatabase
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeInstanceOf import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class PersistanceSourceTest { class PersistanceSourceTest {
private val ctx = mockk<Context>(relaxed = true)
private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true) private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true)
private val source = spyk(PersistenceSource(dbImplementation)) private val source = spyk(PersistenceSource(ctx))
private val scope = CoroutineScope(Dispatchers.IO)
@BeforeEach
fun setup() {
mockkObject(LaunchesDatabase)
every { LaunchesDatabase.getInstance(ctx) } returns dbImplementation
}
@Nested @Nested
inner class GetNextLaunches { inner class GetNextLaunches {
@Test @Test
@ExperimentalCoroutinesApi
fun `should return persistance empty error when db is empty`() { fun `should return persistance empty error when db is empty`() {
runBlockingTest { coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns emptyList()
coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns emptyList()
val result = source.getNextLaunches(10) scope.launch {
result shouldBeInstanceOf Result.Failure::class val result = source.getNextLaunches(10, 0)
result.handleFailure {
it shouldBeInstanceOf Reason.PersistenceEmpty::class result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf PersistenceEmpty::class
} }
} }
} }
@Test @Test
@ExperimentalCoroutinesApi
fun `should return success with data if db is not empty`() { fun `should return success with data if db is not empty`() {
runBlockingTest { coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns listOf(LaunchEntity(id = 1013))
coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns listOf(LaunchEntity(id = 1013))
val result = source.getNextLaunches(10) scope.launch {
result shouldBeInstanceOf Result.Success::class val result = source.getNextLaunches(10, 0)
result.handleSuccess {
result shouldBeInstanceOf Success::class
result.onSuccess {
it.isEmpty() shouldBe false it.isEmpty() shouldBe false
it.size shouldEqualTo 1 it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013 it[0].id shouldEqualTo 1013

View File

@@ -1,141 +0,0 @@
package com.melih.repository.sources
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import io.mockk.called
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class SourceManagerTest {
private val networkSource = mockk<NetworkSource>(relaxed = true)
private val persistenceSource = mockk<PersistenceSource>(relaxed = true)
private val sourceManager = spyk(SourceManager(networkSource, persistenceSource))
@Nested
inner class GetNextLaunches {
@Test
@ExperimentalCoroutinesApi
fun `should try to fetch, save and return result from persistance`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1012)))
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013)))
val result = sourceManager.getNextLaunches(amount)
coVerifyOrder {
networkSource.getNextLaunches(any())
persistenceSource.saveLaunches(any())
persistenceSource.getNextLaunches(any())
}
coVerify(exactly = 1) { networkSource.getNextLaunches(any()) }
coVerify(exactly = 1) { persistenceSource.saveLaunches(any()) }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should not save response if fetching was failure and return result from persistance`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError())
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013)))
val result = sourceManager.getNextLaunches(amount)
coVerify { persistenceSource.saveLaunches(any()) wasNot called }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
@Test
@ExperimentalCoroutinesApi
fun `should return failure if network and persistance fails`() {
runBlockingTest {
val amount = 10
coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError())
coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Failure(Reason.PersistenceEmpty())
val result = sourceManager.getNextLaunches(amount)
coVerify { persistenceSource.saveLaunches(any()) wasNot called }
coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) }
result shouldBeInstanceOf Result.Failure::class
result.handleFailure {
it shouldBeInstanceOf Reason.NoNetworkPersistenceEmpty::class
}
}
}
}
@Nested
inner class GetLaunchDetails {
@Test
fun `should return result from persistance immediately if it's found`() {
runBlocking {
coEvery { persistenceSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013))
val result = sourceManager.getLaunchById(1)
coVerify { networkSource.getLaunchById(any()) wasNot called }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.id shouldEqualTo 1013
}
}
}
@Test
fun `should fetch result from network if it's not found in persistance`() {
runBlocking {
coEvery {
persistenceSource.getLaunchById(any())
} returns Result.Failure(Reason.PersistenceEmpty()) andThen Result.Success(LaunchEntity(id = 1013))
coEvery { networkSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013))
val result = sourceManager.getLaunchById(1)
coVerify(exactly = 1) { networkSource.getLaunchById(any()) }
coVerify(exactly = 1) { persistenceSource.saveLaunch(any()) }
coVerify(exactly = 2) { persistenceSource.getLaunchById(any()) }
result shouldBeInstanceOf Result.Success::class
result.handleSuccess {
it.id shouldEqualTo 1013
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
apply plugin: "de.mannodermaus.android-junit5" apply plugin: "de.mannodermaus.android-junit5"
apply from: "$rootProject.projectDir/scripts/detekt.gradle" apply from: "$rootProject.projectDir/scripts/detekt.gradle"
apply from: "$rootProject.projectDir/scripts/dokka.gradle" apply from: "$rootProject.projectDir/scripts/dokka.gradle"
@@ -17,3 +18,57 @@ dependencies {
testRuntimeOnly testLibraries.jUnitEngine testRuntimeOnly testLibraries.jUnitEngine
} }
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.1"
reportsDir = file("$rootProject.projectDir/reports/jacoco/$project.name")
}
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "Reporting"
description = "Generate Jacoco coverage reports for Debug build"
reports {
xml.enabled = true
html.enabled = true
}
// what to exclude from coverage report
// UI, "noise", generated classes, platform classes, etc.
def excludes = [
'**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
'**/*Fragment.*',
'**/*Activity.*'
]
// generated classes
getClassDirectories().setFrom(
fileTree(
dir: "$buildDir/intermediates/classes/debug",
excludes: excludes
) + fileTree(
dir: "$buildDir/tmp/kotlin-classes/debug",
excludes: excludes
)
)
// sources
getSourceDirectories().setFrom(
files([
android.sourceSets.main.java.srcDirs,
"src/main/kotlin"
])
)
getExecutionData().setFrom(
files("$buildDir/jacoco/testDebugUnitTest.exec")
)
}

View File

@@ -8,7 +8,7 @@ ext {
supportLibraryVersion : "28.0.0", supportLibraryVersion : "28.0.0",
appCompatVersion : "1.1.0-alpha04", appCompatVersion : "1.1.0-alpha04",
lifecycleVersion : "2.2.0-alpha02", lifecycleVersion : "2.2.0-alpha02",
fragmentVersion : "1.1.0-beta01", fragmentVersion : "1.2.0-alpha01",
workManagerVersion : "2.1.0-alpha03", workManagerVersion : "2.1.0-alpha03",
constraintLayoutVesion: "2.0.0-beta1", constraintLayoutVesion: "2.0.0-beta1",
cardViewVersion : "1.0.0", cardViewVersion : "1.0.0",
@@ -22,7 +22,7 @@ ext {
retrofitVersion : "2.6.0", retrofitVersion : "2.6.0",
picassoVersion : "2.71828", picassoVersion : "2.71828",
moshiVersion : "1.8.0", moshiVersion : "1.8.0",
coroutinesVersion : "1.3.0-M1", coroutinesVersion : "1.3.0-M2",
leakCanaryVersion : "2.0-alpha-2", leakCanaryVersion : "2.0-alpha-2",
timberVersion : "4.7.1", timberVersion : "4.7.1",
jUnitVersion : "5.5.0", jUnitVersion : "5.5.0",
@@ -127,6 +127,7 @@ ext {
"com.google.dagger:dagger-compiler:${versions.daggerVersion}", "com.google.dagger:dagger-compiler:${versions.daggerVersion}",
"com.google.dagger:dagger-android-processor:${versions.daggerVersion}" "com.google.dagger:dagger-android-processor:${versions.daggerVersion}"
], ],
moshi : "com.squareup.moshi:moshi-kotlin-codegen:${versions.moshiVersion}"
] ]
testLibraries = [ testLibraries = [

View File

@@ -11,6 +11,7 @@ dependencies {
implementation libraries.fragment implementation libraries.fragment
implementation libraries.lifecycle implementation libraries.lifecycle
implementation libraries.liveDataKTX
implementation libraries.navigation implementation libraries.navigation
implementation libraries.constraintLayout implementation libraries.constraintLayout
} }

Some files were not shown because too many files have changed in this diff Show More