mirror of
https://github.com/melihaksoy/Android-Kotlin-Modulerized-CleanArchitecture.git
synced 2026-04-21 00:11:28 +02:00
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:
committed by
Melih Aksoy
parent
11889446cb
commit
625776609d
@@ -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
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
|||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
/projectFilesBackup
|
||||||
|
|
||||||
# Project reports
|
# Project reports
|
||||||
/reports
|
/reports
|
||||||
|
|||||||
3130
.gradletasknamecache
Normal file
3130
.gradletasknamecache
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
12
build.gradle
12
build.gradle
@@ -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
BIN
core/jacoco.exec
Normal file
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
docs/dependency_hierarchy.png
Normal file
BIN
docs/dependency_hierarchy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/module_graph.png
Normal file
BIN
docs/module_graph.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -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
BIN
features/detail/jacoco.exec
Normal file
Binary file not shown.
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.melih.list.di.scopes
|
||||||
|
|
||||||
|
import javax.inject.Scope
|
||||||
|
|
||||||
|
@Scope
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class DetailFragmentScope
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.melih.list.di.scopes
|
||||||
|
|
||||||
|
import javax.inject.Scope
|
||||||
|
|
||||||
|
@Scope
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class DetailScope
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
BIN
features/launches/jacoco.exec
Normal file
BIN
features/launches/jacoco.exec
Normal file
Binary file not shown.
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.melih.list.di.scopes
|
||||||
|
|
||||||
|
import javax.inject.Scope
|
||||||
|
|
||||||
|
@Scope
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class LaunchesFragmentScope
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.melih.list.di.scopes
|
||||||
|
|
||||||
|
import javax.inject.Scope
|
||||||
|
|
||||||
|
@Scope
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class LaunchesScope
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user