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

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
# Project reports
/reports

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Rocket Science

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

40
app/build.gradle Normal file
View File

@@ -0,0 +1,40 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle"
apply from: "$rootProject.projectDir/scripts/flavors.gradle"
android {
defaultConfig {
applicationId "com.melih.rocketscience"
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
dataBinding {
enabled = true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':core')
implementation project(':features:list')
implementation project(':features:detail')
implementation libraries.coroutines
implementation libraries.navigation
debugImplementation libraries.leakCanary
androidTestImplementation testLibraries.espresso
// These libraries required by dagger to create dependency graph, but not by app
compileOnly libraries.retrofit
compileOnly libraries.room
compileOnly libraries.paging
}

28
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,28 @@
#### OkHttp, Retrofit and Moshi
-dontwarn okhttp3.**
-dontwarn retrofit2.Platform$Java8
-dontwarn okio.**
-dontwarn javax.annotation.**
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
-keepclasseswithmembers class * {
@com.squareup.moshi.* <methods>;
}
-keep @com.squareup.moshi.JsonQualifier interface *
-dontwarn org.jetbrains.annotations.**
-keep class kotlin.Metadata { *; }
-keepclassmembers class kotlin.Metadata {
public <methods>;
}
-keepclassmembers class * {
@com.squareup.moshi.FromJson <methods>;
@com.squareup.moshi.ToJson <methods>;
}
-keepnames @kotlin.Metadata class com.myapp.packagename.model.**
-keep class com.myapp.packagnename.model.** { *; }
# Keeping entities intact
-keep class com.melih.repository.entities.** { *; }

View File

@@ -0,0 +1,36 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.melih.rocketscience">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="false"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name="com.melih.list.ui.LaunchesActivity"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.melih.detail.ui.DetailActivity"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="action.dashboard.open" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,23 @@
package com.melih.rocketscience
import com.melih.core.di.DaggerCoreComponent
import com.melih.rocketscience.di.DaggerAppComponent
import dagger.android.AndroidInjector
import dagger.android.DaggerApplication
import timber.log.Timber
class App : DaggerApplication() {
override fun applicationInjector(): AndroidInjector<out DaggerApplication> =
DaggerAppComponent.factory()
.create(
DaggerCoreComponent.factory()
.create(this)
)
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
}
}

View File

@@ -0,0 +1,20 @@
package com.melih.rocketscience.di
import com.melih.core.di.CoreComponent
import com.melih.rocketscience.App
import dagger.Component
import dagger.android.AndroidInjectionModule
import dagger.android.AndroidInjector
@AppScope
@Component(
modules = [AndroidInjectionModule::class, AppModule::class],
dependencies = [CoreComponent::class]
)
interface AppComponent : AndroidInjector<App> {
@Component.Factory
interface Factory {
fun create(component: CoreComponent): AppComponent
}
}

View File

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

View File

@@ -0,0 +1,6 @@
package com.melih.rocketscience.di
import javax.inject.Scope
@Scope
annotation class AppScope

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1"/>
</vector>

View File

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#008577"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Rocket Science</string>
</resources>

39
build.gradle Normal file
View File

@@ -0,0 +1,39 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.40'
ext.nav_version = '2.1.0-alpha05'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0-beta05'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18"
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.4.2.1"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
plugins {
id "io.gitlab.arturbosch.detekt" version "1.0.0-RC14"
id "org.jetbrains.dokka" version "0.9.18"
}
allprojects {
repositories {
google()
jcenter()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
apply from: "scripts/dependencies.gradle"

1
core/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

32
core/build.gradle Normal file
View File

@@ -0,0 +1,32 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle"
apply from: "$rootProject.projectDir/scripts/flavors.gradle"
android {
dataBinding {
enabled = true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
api project(":repository")
implementation libraries.fragment
implementation libraries.paging
implementation libraries.lifecycle
implementation libraries.navigation
implementation libraries.picasso
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest
compileOnly libraries.room
}

0
core/consumer-rules.pro Normal file
View File

21
core/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

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)
}
}

523
default-detekt-config.yml Normal file
View File

@@ -0,0 +1,523 @@
autoCorrect: true
test-pattern: # Configure exclusions for test sources
active: true
patterns: # Test file regexes
- '.*/test/.*'
- '.*/androidTest/.*'
- '.*Test.kt'
- '.*Spec.kt'
- '.*Spek.kt'
exclude-rule-sets:
- 'comments'
exclude-rules:
- 'NamingRules'
- 'WildcardImport'
- 'MagicNumber'
- 'MaxLineLength'
- 'LateinitUsage'
- 'StringLiteralDuplication'
- 'SpreadOperator'
- 'TooManyFunctions'
- 'ForEachOnRange'
- 'FunctionMaxLength'
- 'TooGenericExceptionCaught'
- 'InstanceOfCheckForException'
build:
maxIssues: 1
weights:
# complexity: 1
# LongParameterList: 1
# style: 1
# comments: 0
processors:
active: true
exclude:
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ClassCountProcessor'
# - 'PackageCountProcessor'
# - 'KtFileCountProcessor'
console-reports:
active: true
exclude:
# - 'ProjectStatisticsReport'
# - 'ComplexityReport'
# - 'NotificationReport'
# - 'FindingsReport'
# - 'BuildFailureReport'
comments:
active: true
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: ([.?!][ \t\n\r\f<])|([.?!]$)
UndocumentedPublicClass:
active: false
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
UndocumentedPublicFunction:
active: false
complexity:
active: true
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
ComplexMethod:
active: true
threshold: 10
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
LabeledExpression:
active: false
ignoredLabels: ""
LargeClass:
active: true
threshold: 600
LongMethod:
active: true
threshold: 60
LongParameterList:
active: true
threshold: 6
ignoreDefaultParameters: false
MethodOverloading:
active: false
threshold: 6
NestedBlockDepth:
active: true
threshold: 4
StringLiteralDuplication:
active: false
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: true
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: "^(_|(ignore|expected).*)"
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverriddenFunctions: false
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: false
methodNames: 'toString,hashCode,equals,finalize'
InstanceOfCheckForException:
active: false
NotImplementedDeclaration:
active: false
PrintStackTrace:
active: false
RethrowCaughtException:
active: false
ReturnFromFinally:
active: false
SwallowedException:
active: false
ignoredExceptionTypes: 'InterruptedException,NumberFormatException,ParseException,MalformedURLException'
ThrowingExceptionFromFinally:
active: false
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: false
exceptions: 'IllegalArgumentException,IllegalStateException,IOException'
ThrowingNewInstanceOfSameException:
active: false
TooGenericExceptionCaught:
active: false
exceptionNames:
- ArrayIndexOutOfBoundsException
- Error
- Exception
- IllegalMonitorStateException
- NullPointerException
- IndexOutOfBoundsException
- RuntimeException
- Throwable
allowedExceptionNameRegex: "^(_|(ignore|expected).*)"
TooGenericExceptionThrown:
active: true
exceptionNames:
- Error
- Exception
- Throwable
- RuntimeException
formatting:
active: true
android: false
autoCorrect: true
ChainWrapping:
active: true
autoCorrect: true
CommentSpacing:
active: true
autoCorrect: true
Filename:
active: true
FinalNewline:
active: true
autoCorrect: true
ImportOrdering:
active: false
Indentation:
active: true
autoCorrect: true
indentSize: 4
continuationIndentSize: 4
MaximumLineLength:
active: true
maxLineLength: 150
ModifierOrdering:
active: true
autoCorrect: true
NoBlankLineBeforeRbrace:
active: true
autoCorrect: true
NoConsecutiveBlankLines:
active: true
autoCorrect: true
NoEmptyClassBody:
active: true
autoCorrect: true
NoItParamInMultilineLambda:
active: false
NoLineBreakAfterElse:
active: true
autoCorrect: true
NoLineBreakBeforeAssignment:
active: true
autoCorrect: true
NoMultipleSpaces:
active: true
autoCorrect: true
NoSemicolons:
active: true
autoCorrect: true
NoTrailingSpaces:
active: true
autoCorrect: true
NoUnitReturn:
active: true
autoCorrect: true
NoUnusedImports:
active: true
autoCorrect: true
NoWildcardImports:
active: true
autoCorrect: true
PackageName:
active: true
autoCorrect: true
ParameterListWrapping:
active: true
autoCorrect: true
indentSize: 4
SpacingAroundColon:
active: true
autoCorrect: true
SpacingAroundComma:
active: true
autoCorrect: true
SpacingAroundCurly:
active: true
autoCorrect: true
SpacingAroundKeyword:
active: true
autoCorrect: true
SpacingAroundOperators:
active: true
autoCorrect: true
SpacingAroundParens:
active: true
autoCorrect: true
SpacingAroundRangeOperator:
active: true
autoCorrect: true
StringTemplate:
active: true
autoCorrect: true
naming:
active: true
ClassNaming:
active: true
classPattern: '[A-Z$][a-zA-Z0-9$]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
forbiddenName: ''
FunctionMaxLength:
active: false
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
minimumFunctionNameLength: 3
FunctionNaming:
active: true
functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$'
excludeClassPattern: '$^'
ignoreOverridden: true
FunctionParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
ignoreOverriddenFunctions: true
MatchingDeclarationName:
active: true
MemberNameEqualsClassName:
active: false
ignoreOverriddenFunction: true
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][A-Za-z0-9]*'
VariableMaxLength:
active: false
maximumVariableNameLength: 64
VariableMinLength:
active: false
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
ignoreOverridden: true
performance:
active: true
ArrayPrimitive:
active: false
ForEachOnRange:
active: true
SpreadOperator:
active: true
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
DuplicateCaseInWhenExpression:
active: true
EqualsAlwaysReturnsTrueOrFalse:
active: false
EqualsWithHashCodeExist:
active: true
ExplicitGarbageCollectionCall:
active: true
InvalidRange:
active: false
IteratorHasNextCallsNextMethod:
active: false
IteratorNotThrowingNoSuchElementException:
active: false
LateinitUsage:
active: false
excludeAnnotatedProperties: ""
ignoreOnClassesPattern: ""
UnconditionalJumpStatementInLoop:
active: false
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: false
UnsafeCast:
active: false
UselessPostfixExpression:
active: false
WrongEqualsTypeParameter:
active: false
style:
active: true
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix: 'to'
EqualsNullCall:
active: false
EqualsOnSignatureLine:
active: false
ExplicitItLambdaParameter:
active: false
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenComment:
active: true
values: 'TODO:,FIXME:,STOPSHIP:'
ForbiddenImport:
active: false
imports: ''
ForbiddenVoid:
active: false
FunctionOnlyReturningConstant:
active: false
ignoreOverridableFunction: true
excludedFunctions: 'describeContents'
LoopWithTooManyJumpStatements:
active: false
maxJumpCount: 1
MagicNumber:
active: true
ignoreNumbers: '-1,0,1,2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
MandatoryBracesIfStatements:
active: false
MaxLineLength:
active: true
maxLineLength: 150
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: false
MayBeConst:
active: false
ModifierOrder:
active: true
NestedClassesVisibility:
active: false
NewLineAtEndOfFile:
active: true
NoTabs:
active: false
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: false
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 2
excludedFunctions: "equals"
excludeLabeled: false
excludeReturnFromLambda: true
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: false
SpacingBetweenPackageAndImports:
active: false
ThrowsCount:
active: true
max: 2
TrailingWhitespace:
active: false
UnderscoresInNumericLiterals:
active: false
acceptableDecimalLength: 5
UnnecessaryAbstractClass:
active: false
excludeAnnotatedClasses: "dagger.Module"
UnnecessaryApply:
active: false
UnnecessaryInheritance:
active: false
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedPrivateClass:
active: false
UnusedPrivateMember:
active: false
allowedNames: "(_|ignored|expected|serialVersionUID)"
UseDataClass:
active: false
excludeAnnotatedClasses: ""
UtilityClassWithPublicConstructor:
active: false
VarCouldBeVal:
active: false
WildcardImport:
active: true
excludeImports: 'java.util.*,kotlinx.android.synthetic.*'

1
features/detail/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,22 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs"
apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
android {
packagingOptions {
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest
}

View File

21
features/detail/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.detail"/>

View File

@@ -0,0 +1,23 @@
package com.melih.detail.di
import com.melih.detail.di.modules.DetailBinds
import com.melih.detail.ui.DetailFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
/**
* Contributes fragments & view models in this module
*/
@Module
abstract class DetailContributor {
// region Contributes
@ContributesAndroidInjector(
modules = [
DetailBinds::class
]
)
abstract fun detailFragment(): DetailFragment
// endregion
}

View File

@@ -0,0 +1,22 @@
package com.melih.detail.di.modules
import androidx.lifecycle.ViewModel
import com.melih.core.di.keys.ViewModelKey
import com.melih.detail.ui.DetailViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import kotlinx.coroutines.ExperimentalCoroutinesApi
@Module
abstract class DetailBinds {
// region ViewModels
@Binds
@IntoMap
@ViewModelKey(DetailViewModel::class)
@ExperimentalCoroutinesApi
abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel
// endregion
}

View File

@@ -0,0 +1,39 @@
package com.melih.detail.ui
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import com.melih.core.actions.EXTRA_LAUNCH_ID
import com.melih.core.base.lifecycle.BaseActivity
import com.melih.detail.R
import com.melih.detail.databinding.DetailActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
const val INVALID_LAUNCH_ID = -1L
class DetailActivity : BaseActivity<DetailActivityBinding>() {
// region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true);
supportActionBar?.setDisplayShowHomeEnabled(true);
}
override fun getLayoutId(): Int = R.layout.activity_detail
override fun createNavHostFragment() =
NavHostFragment.create(
R.navigation.nav_detail,
DetailFragmentArgs.Builder()
.setLaunchId(intent?.extras?.getLong(EXTRA_LAUNCH_ID) ?: INVALID_LAUNCH_ID)
.build()
.toBundle()
)
override fun addNavHostTo(): Int = R.id.container
// endregion
}

View File

@@ -0,0 +1,62 @@
package com.melih.detail.ui
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.View
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe
import com.melih.detail.R
import com.melih.detail.databinding.DetailBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
class DetailFragment : BaseDaggerFragment<DetailBinding>() {
// region Properties
private val args: DetailFragmentArgs by navArgs()
@ExperimentalCoroutinesApi
private val viewModel: DetailViewModel
get() = viewModelFactory.createFor(this)
// endregion
// region Functions
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvDescription.movementMethod = ScrollingMovementMethod()
binding.viewModel = viewModel
viewModel.createParamsFor(args.launchId)
viewModel.loadData()
// Observing state to show loading
observe(viewModel.stateData) {
// Loading can go here, skipping for now
}
// Observing error to show toast with retry action
observe(viewModel.errorData) {
Snackbar.make(
binding.root,
resources.getString(it.messageRes),
Snackbar.LENGTH_INDEFINITE
).setAction(com.melih.core.R.string.retry) {
viewModel.retry()
}.show()
}
observe(viewModel.successData) {
Timber.i("")
}
}
override fun getLayoutId(): Int = R.layout.fragment_detail
// endregion
}

View File

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

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data class="DetailActivityBinding" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
android:background="@color/colorPrimary" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="DetailBinding">
<variable
name="viewModel"
type="com.melih.detail.ui.DetailViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{viewModel.imageUrl}"
android:layout_width="0dp"
android:layout_height="220dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<TextView
android:id="@+id/tvTitle"
style="@style/TitleTextStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{viewModel.rocketName}"
android:textAppearance="@style/TitleTextAppearance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<TextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:layout_marginBottom="@dimen/padding_standard"
android:text="@{viewModel.description}"
android:textAppearance="@style/DescriptionTextAppearance"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_detail"
app:startDestination="@id/detailFragment">
<fragment
android:id="@+id/detailFragment"
android:name="com.melih.detail.ui.DetailFragment"
android:label="DetailFragment"
tools:layout="@layout/fragment_detail">
<argument
android:name="launchId"
android:defaultValue="-1L"
app:argType="long" />
</fragment>
</navigation>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="cd_rocket_image">Image of the rocket</string>
</resources>

View File

@@ -0,0 +1,28 @@
package com.melih.list
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
abstract class BaseTestWithMainThread {
@ExperimentalCoroutinesApi
protected val dispatcher = TestCoroutineDispatcher()
@BeforeEach
@ExperimentalCoroutinesApi
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
@ExperimentalCoroutinesApi
fun tearDown() {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}

View File

@@ -0,0 +1,42 @@
package com.melih.detail
import com.melih.detail.ui.DetailViewModel
import com.melih.list.BaseTestWithMainThread
import com.melih.repository.interactors.GetLaunchDetails
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldEqualTo
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 DetailViewModelTest : BaseTestWithMainThread() {
private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true)
@ExperimentalCoroutinesApi
private val viewModel = spyk(DetailViewModel(getLaunchDetails))
@Test
@ExperimentalCoroutinesApi
fun `loadData should invoke getLauchDetails with provided params`() {
dispatcher.runBlockingTest {
val paramsSlot = slot<GetLaunchDetails.Params>()
viewModel.createParamsFor(1013)
viewModel.loadData()
// init should have called it already due to creation above
verify(exactly = 1) { getLaunchDetails(capture(paramsSlot)) }
paramsSlot.captured.id shouldEqualTo 1013
}
}
}

1
features/list/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,14 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest
}

View File

21
features/list/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<manifest package="com.melih.list" />

View File

@@ -0,0 +1,25 @@
package com.melih.list.di
import com.melih.list.di.modules.LaunchesBinds
import com.melih.list.di.modules.LaunchesProvides
import com.melih.list.ui.LaunchesFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
/**
* Contributes fragments & view models in this module
*/
@Module
abstract class LaunchesContributor {
// region Contributes
@ContributesAndroidInjector(
modules = [
LaunchesProvides::class,
LaunchesBinds::class
]
)
abstract fun listFragment(): LaunchesFragment
// endregion
}

View File

@@ -0,0 +1,22 @@
package com.melih.list.di.modules
import androidx.lifecycle.ViewModel
import com.melih.core.di.keys.ViewModelKey
import com.melih.list.ui.LaunchesViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import kotlinx.coroutines.ExperimentalCoroutinesApi
@Module
abstract class LaunchesBinds {
// region ViewModels
@Binds
@IntoMap
@ViewModelKey(LaunchesViewModel::class)
@ExperimentalCoroutinesApi
abstract fun listViewModel(listViewModel: LaunchesViewModel): ViewModel
// endregion
}

View File

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

View File

@@ -0,0 +1,28 @@
package com.melih.list.ui
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import com.melih.core.base.lifecycle.BaseActivity
import com.melih.list.R
import com.melih.list.databinding.LaunchesActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
class LaunchesActivity : BaseActivity<LaunchesActivityBinding>() {
// region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.toolbar)
}
override fun getLayoutId(): Int = R.layout.activity_launches
override fun createNavHostFragment() =
NavHostFragment.create(R.navigation.nav_launches)
override fun addNavHostTo(): Int = R.id.container
// endregion
}

View File

@@ -0,0 +1,41 @@
package com.melih.list.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import com.melih.core.base.recycler.BaseListAdapter
import com.melih.core.base.recycler.BaseViewHolder
import com.melih.list.databinding.LaunchRowBinding
import com.melih.repository.entities.LaunchEntity
class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BaseListAdapter<LaunchEntity>(
object : DiffUtil.ItemCallback<LaunchEntity>() {
override fun areItemsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean =
oldItem.name == newItem.name
},
itemClickListener
) {
override fun createViewHolder(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): BaseViewHolder<LaunchEntity> =
LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false))
}
class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) {
override fun bind(item: LaunchEntity) {
binding.entity = item
val missions = item.missions
binding.tvDescription.text = if (missions.isNotEmpty()) missions[0].description else ""
binding.executePendingBindings()
}
}

View File

@@ -0,0 +1,114 @@
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.google.android.material.snackbar.Snackbar
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.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
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)
// 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) {
Snackbar.make(
binding.root,
resources.getString(it.messageRes),
Snackbar.LENGTH_INDEFINITE
).setAction(com.melih.core.R.string.retry) {
viewModel.retry()
}.show()
}
observe(viewModel.successData) {
itemList.addAll(it)
launchesAdapter.submitList(itemList)
binding.rocketList.scheduleLayoutAnimation()
}
}
private fun onItemSelected(item: LaunchEntity) {
Timber.i("${item.id}")
startActivity(Actions.openDetailFor(item.id))
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_rocket_list, menu)
(menu.findItem(R.id.search).actionView as SearchView).apply {
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
clearFocus()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
launchesAdapter.submitList(
if (!newText.isNullOrBlank()) {
itemList.filter {
it.rocket.name.contains(
newText,
true
) || (it.missions.size > 0 && it.missions[0].description.contains(
newText,
true
))
}
} else {
itemList
}
)
return true
}
})
}
super.onCreateOptionsMenu(menu, inflater)
}
@ExperimentalCoroutinesApi
override fun onRefresh() {
viewModel.refresh()
}
override fun getLayoutId(): Int = R.layout.fragment_launches
// endregion
}

View File

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

View File

@@ -0,0 +1,22 @@
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@integer/anim_duration">
<translate
android:fromYDelta="-20%"
android:interpolator="@android:anim/decelerate_interpolator"
android:toYDelta="0" />
<alpha
android:fromAlpha="0"
android:interpolator="@android:anim/decelerate_interpolator"
android:toAlpha="1" />
<scale
android:fromXScale="105%"
android:fromYScale="105%"
android:interpolator="@android:anim/decelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="100%"
android:toYScale="100%" />
</set>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/item_enter"
android:animationOrder="normal"
android:delay="15%" />

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data class="LaunchesActivityBinding" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
android:background="@color/colorPrimary" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="ListBinding">
<variable
name="viewModel"
type="com.melih.list.ui.LaunchesViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rocketList"
android:layout_width="0dp"
android:layout_height="0dp"
android:layoutAnimation="@anim/layout_item_enter"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/row_launch" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="LaunchRowBinding">
<variable
name="entity"
type="com.melih.repository.entities.LaunchEntity" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
app:cardCornerRadius="@dimen/corner_radius_standard"
app:cardElevation="10dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="160dp">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{entity.rocket.imageURL}"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<TextView
android:id="@+id/tvDescription"
style="@style/ShortDescriptionTextStyle"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:textAppearance="@style/DescriptionTextAppearance"
app:layout_constraintBottom_toBottomOf="@+id/imgRocket"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
<TextView
android:id="@+id/tvTitle"
style="@style/TitleTextStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{entity.rocket.name}"
android:textAppearance="@style/TitleTextAppearance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toTopOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/search"
android:icon="@android:drawable/ic_menu_search"
android:title="@string/search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView" />
</menu>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_launches"
app:startDestination="@id/launchesFragment">
<fragment
android:id="@+id/launchesFragment"
android:name="com.melih.list.ui.LaunchesFragment"
android:label="LaunchesFragment"
tools:layout="@layout/fragment_launches" />
</navigation>

View File

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

View File

@@ -0,0 +1,4 @@
<resources>
<string name="cd_rocket_image">Image of the rocket</string>
<string name="search">Search</string>
</resources>

View File

@@ -0,0 +1,28 @@
package com.melih.list
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
abstract class BaseTestWithMainThread {
@ExperimentalCoroutinesApi
protected val dispatcher = TestCoroutineDispatcher()
@BeforeEach
@ExperimentalCoroutinesApi
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
@ExperimentalCoroutinesApi
fun tearDown() {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}

View File

@@ -0,0 +1,33 @@
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) }
}
}
}

26
gradle.properties Normal file
View File

@@ -0,0 +1,26 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# For build performance
kotlin.incremental=true
kapt.incremental.apt=true
kapt.use.worker.api=true
kapt.include.compile.classpath=false

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Thu Jun 13 10:50:25 CEST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

172
gradlew vendored Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
repository/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

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