Milestones/ms1 (#16)

* Closes #11

* Closes #13

* Closes #17

* Closes #18

* Closes #19

* Closes #6

* Closes #3

* Closes #12

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

View File

@@ -8,7 +8,6 @@ import androidx.databinding.ViewDataBinding
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.NavigationUI
import dagger.android.support.DaggerAppCompatActivity
import kotlinx.coroutines.ExperimentalCoroutinesApi
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 navHostFragment: NavHostFragment
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,23 +4,16 @@ import android.app.Application
import android.content.Context
import android.net.ConnectivityManager
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.Provides
import javax.inject.Singleton
@Module
class CoreModule {
@Provides
fun provideNetworkInfo(app: Application): NetworkInfo? =
(app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo
fun proivdeAppContext(app: Application): Context = app.applicationContext
@Provides
@Singleton
fun provideLaunchesDatabase(app: Application) =
Room.databaseBuilder(app.applicationContext, LaunchesDatabase::class.java, DB_NAME)
.build()
fun provideNetworkInfo(app: Application): NetworkInfo? =
(app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager).activeNetworkInfo
}

View File

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

View File

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

View File

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

View File

@@ -1,37 +1,32 @@
package com.melih.core.base
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.viewmodel.BaseViewModel
import io.mockk.coVerify
import io.mockk.spyk
import io.mockk.verify
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 BaseViewModelTest {
val baseVm = spyk(TestViewModel())
class BaseViewModelTest : BaseTestWithMainThread() {
@Test
fun `refresh should invoke loadData`() {
val baseVm = spyk(TestViewModel())
baseVm.refresh()
verify(exactly = 1) { baseVm.loadData() }
coVerify(exactly = 1) { baseVm.loadData() }
}
@Test
fun `retry should invoke loadData`() {
val baseVm = spyk(TestViewModel())
baseVm.retry()
verify(exactly = 1) { baseVm.loadData() }
coVerify(exactly = 1) { baseVm.loadData() }
}
}
class TestViewModel : BaseViewModel<Int>() {
override public fun loadData() {
class TestViewModel : BaseViewModel<Unit>() {
override suspend fun loadData() {
// no - op
}
}

View File

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

View File

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