Initial commit

This commit is contained in:
Melih Aksoy
2019-07-01 15:56:55 +02:00
commit 6029facf73
143 changed files with 6891 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.core">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>

View File

@@ -0,0 +1,16 @@
package com.melih.core.actions
import android.content.Intent
const val EXTRA_LAUNCH_ID = "extras:detail:launchid"
/**
* Navigation actions for navigation between feature activities
*/
object Actions {
fun openDetailFor(id: Long) =
Intent("action.dashboard.open")
.putExtra(EXTRA_LAUNCH_ID, id)
}

View File

@@ -0,0 +1,58 @@
package com.melih.core.base.lifecycle
import android.os.Bundle
import androidx.annotation.IdRes
import androidx.annotation.LayoutRes
import androidx.databinding.DataBindingUtil
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"
/**
* Base class of all Activity classes
*/
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)
binding = DataBindingUtil.setContentView(this, getLayoutId())
binding.lifecycleOwner = this
if (savedInstanceState == null) {
navHostFragment = createNavHostFragment()
supportFragmentManager
.beginTransaction()
.add(addNavHostTo(), navHostFragment, NAV_HOST_FRAGMENT_TAG)
.commitNow()
} else {
navHostFragment = supportFragmentManager
.findFragmentByTag(NAV_HOST_FRAGMENT_TAG) as NavHostFragment
}
}
override fun onSupportNavigateUp(): Boolean {
if (!NavigationUI.navigateUp(navHostFragment.navController, null)) {
onBackPressed()
}
return true
}
@LayoutRes
abstract fun getLayoutId(): Int
abstract fun createNavHostFragment(): NavHostFragment
@IdRes
abstract fun addNavHostTo(): Int
}

View File

@@ -0,0 +1,44 @@
package com.melih.core.base.lifecycle
import android.content.Context
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import com.melih.core.di.ViewModelFactory
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.AndroidSupportInjection
import dagger.android.support.HasSupportFragmentInjector
import javax.inject.Inject
/**
* Parent of fragments which has injections. Aim is to seperate [BaseFragment] functionality for fragments which
* won't need any injection.
*
* Note that fragments that extends from [BaseDaggerFragment] should contribute android injector.
*
* This class provides [viewModelFactory] which serves as factory for view models
* in the project. It's injected by map of view models that this app is serving. Check [ViewModelFactory]
* to see how it works.
*/
abstract class BaseDaggerFragment<T : ViewDataBinding> : BaseFragment<T>(), HasSupportFragmentInjector {
// region Properties
@get:Inject
internal var childFragmentInjector: DispatchingAndroidInjector<Fragment>? = null
@Inject
lateinit var viewModelFactory: ViewModelFactory
// endregion
// region Functions
override fun onAttach(context: Context) {
AndroidSupportInjection.inject(this)
super.onAttach(context)
}
override fun supportFragmentInjector(): AndroidInjector<Fragment>? = childFragmentInjector
// endregion
}

View File

@@ -0,0 +1,44 @@
package com.melih.core.base.lifecycle
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
/**
* Parent of all fragments.
*
* Purpose of [BaseFragment] is to simplify view creation and provide easy access to fragment's
* [navController] and [binding].
*/
abstract class BaseFragment<T : ViewDataBinding> : Fragment() {
// region Properties
protected lateinit var navController: NavController
protected lateinit var binding: T
// endregion
// region Functions
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
navController = NavHostFragment.findNavController(this)
binding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false)
binding.lifecycleOwner = this
return binding.root
}
@LayoutRes
abstract fun getLayoutId(): Int
// endregion
}

View File

@@ -0,0 +1,72 @@
package com.melih.core.base.recycler
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
/**
* Base adapter to reduce boilerplate on creating / binding view holders.
*
*
*/
abstract class BaseListAdapter<T>(
callback: DiffUtil.ItemCallback<T>,
private val clickListener: (T) -> Unit
) : ListAdapter<T, BaseViewHolder<T>>(callback) {
private var itemClickListener: ((T) -> Unit)? = null
/**
* This method will be called to create view holder to obfuscate layout inflation creation / process
*
* @param inflater layout inflator
* @param parent parent view group
* @param viewType viewType of holder
*/
abstract fun createViewHolder(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): BaseViewHolder<T>
/**
* [createViewHolder] will provide holders, no need to override this
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder<T> =
createViewHolder(
LayoutInflater.from(parent.context),
parent,
viewType
)
/**
* Calls [bind][BaseViewHolder.bind] on view holders
*/
override fun onBindViewHolder(holder: BaseViewHolder<T>, position: Int) {
val item = getItem(position)
holder.itemView.setOnClickListener {
clickListener(item)
}
holder.bind(item)
}
}
/**
* Base view holder takes view data binding
*/
abstract class BaseViewHolder<T>(binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) {
/**
* Items are delivered to [bind] via [BaseListAdapter.onBindViewHolder]
*
* @param item entity
* @param position position from adapter
*/
abstract fun bind(item: T)
}

View File

@@ -0,0 +1,89 @@
package com.melih.core.base.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
/**
* Base [ViewModel] for view models that will process data.
*
* This view model provides state & error with [stateData] & [errorData] respectively.
*/
abstract class BaseViewModel<T> : ViewModel() {
// region Abstractions
abstract fun loadData()
// endregion
// region Properties
private val _successData = MutableLiveData<T>()
private val _stateData = MutableLiveData<Result.State>()
private val _errorData = MutableLiveData<Reason>()
/**
* Observe [successData] to get notified of data if it's successfuly fetched
*/
val successData: LiveData<T>
get() = _successData
/**
* Observe [stateData] to get notified of state of data
*/
val stateData: LiveData<Result.State>
get() = _stateData
/**
* Observe [errorData] to get notified if an error occurs
*/
val errorData: LiveData<Reason>
get() = _errorData
// endregion
// region Functions
/**
* Default success handler which assigns given [data] to [successData]
*
* @param data success data
*/
protected fun handleSuccess(data: T) {
_successData.value = data
}
/**
* Default state handler which assigns given [state] to [stateData]
*
* @param state state of operation
*/
protected fun handleState(state: Result.State) {
_stateData.value = state
}
/**
* Default error handler which assign received [error] to [errorData]
*
* @param error check [Error] class for possible error types
*/
protected fun handleFailure(reason: Reason) {
_errorData.value = reason
}
/**
* Reload data
*/
fun refresh() {
loadData()
}
/**
* Retry loading data, incase there's difference between refresh and retry, should go here
*/
fun retry() {
loadData()
}
// endregion
}

View File

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

View File

@@ -0,0 +1,26 @@
package com.melih.core.di
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
@Provides
@Singleton
fun provideLaunchesDatabase(app: Application) =
Room.databaseBuilder(app.applicationContext, LaunchesDatabase::class.java, DB_NAME)
.build()
}

View File

@@ -0,0 +1,30 @@
package com.melih.core.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import javax.inject.Inject
import javax.inject.Provider
/**
* [Factory][ViewModelProvider.Factory] that provides view models allowing injection. [viewModelMap] is provided via dagger
* injection. To be able to inject a view model, it must be bound to map via [dagger.Binds] [dagger.multibindings.IntoMap]
* by using [ViewModelKey][com.melih.core.di.keys.ViewModelKey].
*
*/
@Suppress("UNCHECKED_CAST")
class ViewModelFactory @Inject constructor(
private val viewModelMap: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>>
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val viewModelProvider: Provider<ViewModel> = viewModelMap[modelClass]
?: throw IllegalArgumentException("Unknown ViewModel")
return try {
viewModelProvider.get() as T
?: throw IllegalArgumentException("Provider's contained value is null")
} catch (e: ClassCastException) {
throw e
}
}
}

View File

@@ -0,0 +1,9 @@
package com.melih.core.di.keys
import androidx.lifecycle.ViewModel
import dagger.MapKey
import kotlin.reflect.KClass
@MapKey
@Target(AnnotationTarget.FUNCTION)
annotation class ViewModelKey(val value: KClass<out ViewModel>)

View File

@@ -0,0 +1,19 @@
package com.melih.core.extensions
import android.widget.ImageView
import androidx.databinding.BindingAdapter
import com.squareup.picasso.Picasso
/**
* Loads image in given [url] to this [ImageView]
*
* @param url url of image
*/
@BindingAdapter("imageUrl")
fun ImageView.loadImage(url: String?) {
if (!url.isNullOrBlank()) {
Picasso.get()
.load(url)
.into(this)
}
}

View File

@@ -0,0 +1,32 @@
package com.melih.core.extensions
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
/**
* Reduces required boilerplate code to observe a live data
*
* @param data [LiveData] to observe
* @param block receive and process data
*/
fun <T> Fragment.observe(data: LiveData<T>, block: (T) -> Unit) {
data.observe(this, Observer(block))
}
/**
* Method for getting viewModel from factory and run a block over it if required for easy access
*
* crossinline for unwanted returns
*/
inline fun <reified T : ViewModel> ViewModelProvider.Factory.createFor(
fragment: Fragment,
crossinline block: T.() -> Unit = {}
): T {
val viewModel = ViewModelProviders.of(fragment, this)[T::class.java]
viewModel.apply(block)
return viewModel
}

View File

@@ -0,0 +1,23 @@
package com.melih.core.utils
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
class SnackbarBehaviour constructor(
context: Context,
attributeSet: AttributeSet
) : CoordinatorLayout.Behavior<SwipeRefreshLayout>() {
override fun layoutDependsOn(parent: CoordinatorLayout, child: SwipeRefreshLayout, dependency: View): Boolean =
dependency is Snackbar.SnackbarLayout
override fun onDependentViewChanged(parent: CoordinatorLayout, child: SwipeRefreshLayout, dependency: View): Boolean {
val translationY = Math.min(0.0f, (dependency.translationY - dependency.height))
child.translationY = translationY
return true
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#008577</color>
<color name="colorPrimaryDark">#00574B</color>
<color name="colorAccent">#D81B60</color>
<color name="lightGray">#8F8F8F</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="padding_standard">8dp</dimen>
<dimen name="corner_radius_standard">11dp</dimen>
</resources>

View File

@@ -0,0 +1,15 @@
<resources>
<string name="dummy_long_text">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore
magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum
</string>
<string name="retry">Retry</string>
<!--Actions-->
<string name="action_detail">action.detail.open</string>
</resources>

View File

@@ -0,0 +1,35 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<!--Styles-->
<style name="TitleTextStyle">
<item name="android:singleLine">true</item>
</style>
<style name="ShortDescriptionTextStyle">
<item name="android:ellipsize">end</item>
<item name="android:maxLines">@integer/common_max_lines</item>
<item name="android:gravity">center|left</item>
</style>
<style name="DescriptionTextStyle">
</style>
<!--Text appearances-->
<style name="TitleTextAppearance" parent="TextAppearance.AppCompat.Title" />
<style name="DescriptionTextAppearance" parent="TextAppearance.AppCompat.Body1">
<item name="android:textColor">@color/lightGray</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="common_max_lines">5</integer>
</resources>

View File

@@ -0,0 +1,49 @@
package com.melih.core
import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
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.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
abstract class BaseTestWithMainThread {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@BeforeEach
@ExperimentalCoroutinesApi
fun setUp() {
Dispatchers.setMain(dispatcher)
ArchTaskExecutor.getInstance()
.setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
override fun isMainThread(): Boolean = true
override fun postToMainThread(runnable: Runnable) = runnable.run()
})
}
@AfterEach
@ExperimentalCoroutinesApi
fun tearDown() {
Dispatchers.resetMain()
dispatcher.close()
ArchTaskExecutor.getInstance()
.setDelegate(null)
}
}
suspend fun <T> LiveData<T>.testObserve(onChangeHandler: (T) -> Unit) {
suspendCoroutine<Unit> {
val observer = OneShotObserverWithLifecycle(onChangeHandler, it)
observe(observer, observer)
}
}

View File

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

View File

@@ -0,0 +1,34 @@
package com.melih.core.observers
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.Observer
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
/**
* This class is both [Observer] & [LifecycleOwner], used to observe on live data via
* [testObserve].
*
* Taking continuation is due to suspending coroutine, else scope is getting closed right away after
* reaching end of suspending job and test is over.
*/
class OneShotObserverWithLifecycle<T>(
val block: (T) -> Unit, val
continuation: Continuation<Unit>
) : LifecycleOwner, Observer<T> {
private val lifecycle = LifecycleRegistry(this)
init {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
}
override fun getLifecycle(): Lifecycle = lifecycle
override fun onChanged(t: T) {
block(t)
continuation.resume(Unit)
}
}