commit 6029facf73b7c0384e820e43e26ecf109f158b31 Author: Melih Aksoy Date: Mon Jul 1 15:56:55 2019 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84d7b28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild + +# Project reports +/reports diff --git a/README.md b/README.md new file mode 100644 index 0000000..05d7f6d --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Rocket Science diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..b99eac8 --- /dev/null +++ b/app/build.gradle @@ -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 +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..da2a358 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,28 @@ +#### OkHttp, Retrofit and Moshi +-dontwarn okhttp3.** +-dontwarn retrofit2.Platform$Java8 +-dontwarn okio.** +-dontwarn javax.annotation.** +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} +-keepclasseswithmembers class * { + @com.squareup.moshi.* ; +} +-keep @com.squareup.moshi.JsonQualifier interface * +-dontwarn org.jetbrains.annotations.** +-keep class kotlin.Metadata { *; } +-keepclassmembers class kotlin.Metadata { + public ; +} + +-keepclassmembers class * { + @com.squareup.moshi.FromJson ; + @com.squareup.moshi.ToJson ; +} + +-keepnames @kotlin.Metadata class com.myapp.packagename.model.** +-keep class com.myapp.packagnename.model.** { *; } + +# Keeping entities intact +-keep class com.melih.repository.entities.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..edb02fb --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/melih/rocketscience/App.kt b/app/src/main/kotlin/com/melih/rocketscience/App.kt new file mode 100644 index 0000000..b2d8e13 --- /dev/null +++ b/app/src/main/kotlin/com/melih/rocketscience/App.kt @@ -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 = + DaggerAppComponent.factory() + .create( + DaggerCoreComponent.factory() + .create(this) + ) + + + override fun onCreate() { + super.onCreate() + + Timber.plant(Timber.DebugTree()) + } +} diff --git a/app/src/main/kotlin/com/melih/rocketscience/di/AppComponent.kt b/app/src/main/kotlin/com/melih/rocketscience/di/AppComponent.kt new file mode 100644 index 0000000..3ed9830 --- /dev/null +++ b/app/src/main/kotlin/com/melih/rocketscience/di/AppComponent.kt @@ -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 { + + @Component.Factory + interface Factory { + fun create(component: CoreComponent): AppComponent + } +} diff --git a/app/src/main/kotlin/com/melih/rocketscience/di/AppModule.kt b/app/src/main/kotlin/com/melih/rocketscience/di/AppModule.kt new file mode 100644 index 0000000..c80bd10 --- /dev/null +++ b/app/src/main/kotlin/com/melih/rocketscience/di/AppModule.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/melih/rocketscience/di/AppScope.kt b/app/src/main/kotlin/com/melih/rocketscience/di/AppScope.kt new file mode 100644 index 0000000..38a7fd0 --- /dev/null +++ b/app/src/main/kotlin/com/melih/rocketscience/di/AppScope.kt @@ -0,0 +1,6 @@ +package com.melih.rocketscience.di + +import javax.inject.Scope + +@Scope +annotation class AppScope diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..6348baa --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..c206d43 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..898f3ed Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dffca36 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..64ba76f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..dae5e08 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e5ed465 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..14ed0af Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b0907ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d8ae031 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2c18de9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..beed3cd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7308232 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Rocket Science + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..523c5df --- /dev/null +++ b/build.gradle @@ -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" diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..5407e3d --- /dev/null +++ b/core/build.gradle @@ -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 +} diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/core/proguard-rules.pro @@ -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 diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7a7f527 --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/core/src/main/kotlin/com/melih/core/actions/Actions.kt b/core/src/main/kotlin/com/melih/core/actions/Actions.kt new file mode 100644 index 0000000..8fbd473 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/actions/Actions.kt @@ -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) + +} diff --git a/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseActivity.kt b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseActivity.kt new file mode 100644 index 0000000..33f8285 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseActivity.kt @@ -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 : 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 +} diff --git a/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseDaggerFragment.kt b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseDaggerFragment.kt new file mode 100644 index 0000000..7660275 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseDaggerFragment.kt @@ -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 : BaseFragment(), HasSupportFragmentInjector { + + // region Properties + + @get:Inject + internal var childFragmentInjector: DispatchingAndroidInjector? = null + + @Inject + lateinit var viewModelFactory: ViewModelFactory + // endregion + + // region Functions + + override fun onAttach(context: Context) { + AndroidSupportInjection.inject(this) + super.onAttach(context) + } + + override fun supportFragmentInjector(): AndroidInjector? = childFragmentInjector + // endregion +} diff --git a/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseFragment.kt b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseFragment.kt new file mode 100644 index 0000000..86f2ed9 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/base/lifecycle/BaseFragment.kt @@ -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 : 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 +} diff --git a/core/src/main/kotlin/com/melih/core/base/recycler/BaseListAdapter.kt b/core/src/main/kotlin/com/melih/core/base/recycler/BaseListAdapter.kt new file mode 100644 index 0000000..8ec796b --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/base/recycler/BaseListAdapter.kt @@ -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( + callback: DiffUtil.ItemCallback, + private val clickListener: (T) -> Unit +) : ListAdapter>(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 + + /** + * [createViewHolder] will provide holders, no need to override this + */ + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder = + createViewHolder( + LayoutInflater.from(parent.context), + parent, + viewType + ) + + /** + * Calls [bind][BaseViewHolder.bind] on view holders + */ + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + val item = getItem(position) + + holder.itemView.setOnClickListener { + clickListener(item) + } + + holder.bind(item) + + } +} + +/** + * Base view holder takes view data binding + */ +abstract class BaseViewHolder(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) +} diff --git a/core/src/main/kotlin/com/melih/core/base/viewmodel/BaseViewModel.kt b/core/src/main/kotlin/com/melih/core/base/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..d30406b --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/base/viewmodel/BaseViewModel.kt @@ -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 : ViewModel() { + + // region Abstractions + + abstract fun loadData() + // endregion + + // region Properties + + private val _successData = MutableLiveData() + private val _stateData = MutableLiveData() + private val _errorData = MutableLiveData() + + /** + * Observe [successData] to get notified of data if it's successfuly fetched + */ + val successData: LiveData + get() = _successData + + /** + * Observe [stateData] to get notified of state of data + */ + val stateData: LiveData + get() = _stateData + + /** + * Observe [errorData] to get notified if an error occurs + */ + val errorData: LiveData + 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 +} diff --git a/core/src/main/kotlin/com/melih/core/di/CoreComponent.kt b/core/src/main/kotlin/com/melih/core/di/CoreComponent.kt new file mode 100644 index 0000000..859796c --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/di/CoreComponent.kt @@ -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 + } +} diff --git a/core/src/main/kotlin/com/melih/core/di/CoreModule.kt b/core/src/main/kotlin/com/melih/core/di/CoreModule.kt new file mode 100644 index 0000000..1867c58 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/di/CoreModule.kt @@ -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() +} diff --git a/core/src/main/kotlin/com/melih/core/di/ViewModelFactory.kt b/core/src/main/kotlin/com/melih/core/di/ViewModelFactory.kt new file mode 100644 index 0000000..665b430 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/di/ViewModelFactory.kt @@ -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, @JvmSuppressWildcards Provider> +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + val viewModelProvider: Provider = 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 + } + } +} diff --git a/core/src/main/kotlin/com/melih/core/di/keys/ViewModelKey.kt b/core/src/main/kotlin/com/melih/core/di/keys/ViewModelKey.kt new file mode 100644 index 0000000..ee0c358 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/di/keys/ViewModelKey.kt @@ -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) diff --git a/core/src/main/kotlin/com/melih/core/extensions/BindingAdapters.kt b/core/src/main/kotlin/com/melih/core/extensions/BindingAdapters.kt new file mode 100644 index 0000000..e3a13b3 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/extensions/BindingAdapters.kt @@ -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) + } +} diff --git a/core/src/main/kotlin/com/melih/core/extensions/LifecycleExtensions.kt b/core/src/main/kotlin/com/melih/core/extensions/LifecycleExtensions.kt new file mode 100644 index 0000000..5798815 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/extensions/LifecycleExtensions.kt @@ -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 Fragment.observe(data: LiveData, 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 ViewModelProvider.Factory.createFor( + fragment: Fragment, + crossinline block: T.() -> Unit = {} +): T { + val viewModel = ViewModelProviders.of(fragment, this)[T::class.java] + viewModel.apply(block) + return viewModel +} diff --git a/core/src/main/kotlin/com/melih/core/utils/SnackbarBehaviour.kt b/core/src/main/kotlin/com/melih/core/utils/SnackbarBehaviour.kt new file mode 100644 index 0000000..2528400 --- /dev/null +++ b/core/src/main/kotlin/com/melih/core/utils/SnackbarBehaviour.kt @@ -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() { + + 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 + } +} diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml new file mode 100644 index 0000000..270aec4 --- /dev/null +++ b/core/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #008577 + #00574B + #D81B60 + #8F8F8F + diff --git a/core/src/main/res/values/dimens.xml b/core/src/main/res/values/dimens.xml new file mode 100644 index 0000000..ab4df8e --- /dev/null +++ b/core/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 8dp + 11dp + \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml new file mode 100644 index 0000000..22788f1 --- /dev/null +++ b/core/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + 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 + + + Retry + + + action.detail.open + diff --git a/core/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml new file mode 100644 index 0000000..f7ae6d6 --- /dev/null +++ b/core/src/main/res/values/styles.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/res/values/values.xml b/core/src/main/res/values/values.xml new file mode 100644 index 0000000..33e2994 --- /dev/null +++ b/core/src/main/res/values/values.xml @@ -0,0 +1,4 @@ + + + 5 + \ No newline at end of file diff --git a/core/src/test/kotlin/com/melih/core/BaseTestWithMainThread.kt b/core/src/test/kotlin/com/melih/core/BaseTestWithMainThread.kt new file mode 100644 index 0000000..203a012 --- /dev/null +++ b/core/src/test/kotlin/com/melih/core/BaseTestWithMainThread.kt @@ -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 LiveData.testObserve(onChangeHandler: (T) -> Unit) { + suspendCoroutine { + val observer = OneShotObserverWithLifecycle(onChangeHandler, it) + observe(observer, observer) + } +} diff --git a/core/src/test/kotlin/com/melih/core/base/BaseViewModelTest.kt b/core/src/test/kotlin/com/melih/core/base/BaseViewModelTest.kt new file mode 100644 index 0000000..4436517 --- /dev/null +++ b/core/src/test/kotlin/com/melih/core/base/BaseViewModelTest.kt @@ -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() { + override public fun loadData() { + // no - op + } + +} diff --git a/core/src/test/kotlin/com/melih/core/observers/OneShotObserverWithLifecycle.kt b/core/src/test/kotlin/com/melih/core/observers/OneShotObserverWithLifecycle.kt new file mode 100644 index 0000000..dcdad09 --- /dev/null +++ b/core/src/test/kotlin/com/melih/core/observers/OneShotObserverWithLifecycle.kt @@ -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( + val block: (T) -> Unit, val + continuation: Continuation +) : LifecycleOwner, Observer { + + 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) + } +} diff --git a/default-detekt-config.yml b/default-detekt-config.yml new file mode 100644 index 0000000..326fe3c --- /dev/null +++ b/default-detekt-config.yml @@ -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.*' diff --git a/features/detail/.gitignore b/features/detail/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/features/detail/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/detail/build.gradle b/features/detail/build.gradle new file mode 100644 index 0000000..e380aa6 --- /dev/null +++ b/features/detail/build.gradle @@ -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 +} diff --git a/features/detail/consumer-rules.pro b/features/detail/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/detail/proguard-rules.pro b/features/detail/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/features/detail/proguard-rules.pro @@ -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 diff --git a/features/detail/sampledata/launches.json b/features/detail/sampledata/launches.json new file mode 100644 index 0000000..3e8569c --- /dev/null +++ b/features/detail/sampledata/launches.json @@ -0,0 +1,1239 @@ +{ + "launches": [ + { + "id": 1946, + "name": "Long March 3B/E | Beidou-3 IGSO-2", + "windowstart": "June 24, 2019 17:52:00 UTC", + "windowend": "June 24, 2019 18:28:00 UTC", + "net": "June 24, 2019 17:52:00 UTC", + "wsstamp": 1561398720, + "westamp": 1561400880, + "netstamp": 1561398720, + "isostart": "20190624T175200Z", + "isoend": "20190624T182800Z", + "isonet": "20190624T175200Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-21 07:01:11", + "location": { + "pads": [ + { + "id": 143, + "name": "Launch Complex 3 ( LC-3 ) ( LA-1 ), Xichang Satellite Launch Center", + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/Xichang_Satellite_Launch_Center", + "mapURL": "https://www.google.com/maps/?q=28.246017,102.026556", + "latitude": 28.246017, + "longitude": 102.026556, + "agencies": [ + { + "id": 17, + "name": "China National Space Administration", + "abbrev": "CNSA", + "countryCode": "CHN", + "type": 1, + "infoURL": "http://www.cnsa.gov.cn/", + "wikiURL": "http://en.wikipedia.org/wiki/China_National_Space_Administration", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.cnsa.gov.cn/" + ] + } + ] + } + ], + "id": 25, + "name": "Xichang Satellite Launch Center, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 69, + "name": "Long March 3B/E", + "configuration": "B/E", + "familyname": "Long March 3", + "agencies": [ + { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Long_March_3B", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/LongMarch3BE.jpg_1024.jpg" + }, + "missions": [ + { + "id": 1216, + "name": "Beidou-3 IGSO-2", + "description": "These two satellites will be used to provide global navigation coverage as part of the Chinese Beidou (Compass) satellite navigation system.", + "type": 15, + "wikiURL": "https://en.wikipedia.org/wiki/BeiDou_Navigation_Satellite_System", + "typeName": "Navigation", + "agencies": null, + "payloads": [] + } + ], + "lsp": { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + }, + { + "id": 318, + "name": "Falcon Heavy | STP-2", + "windowstart": "June 25, 2019 03:30:00 UTC", + "windowend": "June 25, 2019 07:30:00 UTC", + "net": "June 25, 2019 03:30:00 UTC", + "wsstamp": 1561433400, + "westamp": 1561447800, + "netstamp": 1561433400, + "isostart": "20190625T033000Z", + "isoend": "20190625T073000Z", + "isonet": "20190625T033000Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [ + "http://www.spacex.com/webcast/" + ], + "vidURL": null, + "infoURLs": [ + "https://www.spacex.com/stp-2" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": 70, + "hashtag": null, + "changed": "2019-06-21 16:24:03", + "location": { + "pads": [ + { + "id": 87, + "name": "Launch Complex 39A, Kennedy Space Center, FL", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Kennedy_Space_Center_Launch_Complex_39#Launch_Pad_39A", + "mapURL": "http://maps.google.com/maps?q=28.608+N,+80.604+W", + "latitude": 28.60822681, + "longitude": -80.60428186, + "agencies": [] + } + ], + "id": 17, + "name": "Kennedy Space Center, FL, USA", + "infoURL": "", + "wikiURL": "", + "countryCode": "USA" + }, + "rocket": { + "id": 58, + "name": "Falcon Heavy", + "configuration": "Heavy", + "familyname": "Falcon", + "agencies": [ + { + "id": 121, + "name": "SpaceX", + "abbrev": "SpX", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/SpaceX", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.spacex.com/", + "https://twitter.com/SpaceX", + "https://www.youtube.com/channel/UCtI0Hodo5o5dUb67FeUjDeA" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Falcon_Heavy", + "infoURLs": [ + "http://www.spacex.com/falcon-heavy" + ], + "infoURL": "http://www.spacex.com/falcon-heavy", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920, + 2560 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/FalconHeavy.jpg_2560.jpg" + }, + "missions": [ + { + "id": 687, + "name": "STP-2", + "description": "The STP-2 payload is composed of 25 small spacecraft. Included is COSMIC-2 constellation to provide radio occultation data, along with 8 cubesat nanosatellites. \nOther payloads include LightSail carried by the Prox-1 nanosatellite, Oculus-ASR nanosatellite, GPIM and the Deep Space Atomic Clock.", + "type": 14, + "wikiURL": "https://en.wikipedia.org/wiki/Space_Test_Program", + "typeName": "Dedicated Rideshare", + "agencies": [ + { + "id": 161, + "name": "United States Air Force", + "abbrev": "USAF", + "countryCode": "USA", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/United_States_Air_Force", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.af.mil", + "https://www.facebook.com/USairforce", + "https://twitter.com/USairforce", + "https://www.youtube.com/afbluetube", + "https://www.instagram.com/usairforce", + "https://www.flickr.com/photos/usairforce" + ] + } + ], + "payloads": [ + { + "id": 444, + "name": "DSX (Demonstration & Science Experiments)" + }, + { + "id": 445, + "name": "Formosat-7/Cosmic-2" + }, + { + "id": 446, + "name": "GPIM (Green Propellant Infusion Mission)" + }, + { + "id": 447, + "name": "OTB-1" + }, + { + "id": 448, + "name": "Oculus-ASR" + }, + { + "id": 449, + "name": "NPSAT1" + }, + { + "id": 450, + "name": "Prox-1" + }, + { + "id": 451, + "name": "Lightsail-B" + }, + { + "id": 452, + "name": "E-TBex A, B" + }, + { + "id": 453, + "name": "PSAT-2 (ParkinsonSAT-2)" + }, + { + "id": 454, + "name": "TEPCE 1, 2" + }, + { + "id": 455, + "name": "CP 9 (LEO)" + }, + { + "id": 456, + "name": "StangSat" + } + ] + } + ], + "lsp": { + "id": 121, + "name": "SpaceX", + "abbrev": "SpX", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/SpaceX", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.spacex.com/", + "https://twitter.com/SpaceX", + "https://www.youtube.com/channel/UCtI0Hodo5o5dUb67FeUjDeA" + ] + } + }, + { + "id": 1932, + "name": "Electron | Make It Rain", + "windowstart": "June 27, 2019 04:30:00 UTC", + "windowend": "June 27, 2019 06:30:00 UTC", + "net": "June 27, 2019 04:30:00 UTC", + "wsstamp": 1561609800, + "westamp": 1561617000, + "netstamp": 1561609800, + "isostart": "20190627T043000Z", + "isoend": "20190627T063000Z", + "isonet": "20190627T043000Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-17 07:50:23", + "location": { + "pads": [ + { + "id": 166, + "name": "Rocket Lab Launch Complex 1", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Rocket_Lab_Launch_Complex_1", + "mapURL": "https://www.google.ee/maps/place/39°15'46.2\"S+177°51'52.1\"E/", + "latitude": -39.262833, + "longitude": 177.864469, + "agencies": [ + { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + ] + } + ], + "id": 40, + "name": "Onenui Station, Mahia Peninsula, New Zealand", + "infoURL": "", + "wikiURL": "", + "countryCode": "NZL" + }, + "rocket": { + "id": 148, + "name": "Electron", + "configuration": "", + "familyname": "Electron", + "agencies": [ + { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Rocket_Lab#Electron_Launch_Vehicle", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/Electron.jpg_1440.jpg" + }, + "missions": [ + { + "id": 1209, + "name": "Make It Rain", + "description": "Rideshare mission for Spaceflight. The mission is named \"Make it Rain\" in a nod to the high volume of rainfall in Seattle, where Spaceflight is headquartered, as well in New Zealand where Launch Complex 1 is located. Among the satellites on the mission for Spaceflight are BlackSky’s Global-4, two U.S. Special Operations Command (USSOCOM) Prometheus and Melbourne Space Program’s ACRUX-1.", + "type": 14, + "wikiURL": "", + "typeName": "Dedicated Rideshare", + "agencies": null, + "payloads": [ + { + "id": 457, + "name": "BlackSky Global 3" + }, + { + "id": 458, + "name": "ACRUX 1" + }, + { + "id": 459, + "name": "SpaceBEE 8" + }, + { + "id": 460, + "name": "SpaceBEE 9" + }, + { + "id": 461, + "name": "Prometheus-2 5,6" + } + ] + } + ], + "lsp": { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + }, + { + "id": 1937, + "name": "Kuaizhou-11 | Maiden Flight", + "windowstart": "June 30, 2019 00:00:00 UTC", + "windowend": "June 30, 2019 00:00:00 UTC", + "net": "June 30, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190630T000000Z", + "isoend": "20190630T000000Z", + "isonet": "20190630T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-06 17:15:06", + "location": { + "pads": [ + { + "id": 115, + "name": "Unknown Pad, Jiuquan", + "infoURL": "", + "wikiURL": "", + "mapURL": "", + "latitude": 40.958, + "longitude": 100.291, + "agencies": null + } + ], + "id": 1, + "name": "Jiuquan, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 223, + "name": "Kuaizhou-11", + "configuration": "11", + "familyname": "Kuaizhou", + "agencies": null, + "wikiURL": "https://en.wikipedia.org/wiki/Kuaizhou#Models", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 1212, + "name": "Maiden Flight", + "description": "First flight of the new solid launcher developed by ExPace, subsidiary of CASIC. It will carry 2 communication satellites on this launch.", + "type": 13, + "wikiURL": "", + "typeName": "Test Flight", + "agencies": null, + "payloads": [] + } + ], + "lsp": { + "id": 194, + "name": "ExPace", + "abbrev": "EP", + "countryCode": "CHN", + "type": 3, + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/ExPace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [] + } + }, + { + "id": 1137, + "name": "Long March 4B | CBERS-4A", + "windowstart": "July 1, 2019 00:00:00 UTC", + "windowend": "July 1, 2019 00:00:00 UTC", + "net": "July 1, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190701T000000Z", + "isoend": "20190701T000000Z", + "isonet": "20190701T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2017-02-21 00:00:00", + "location": { + "pads": [ + { + "id": 116, + "name": "Unknown Pad, Taiyuan", + "infoURL": "", + "wikiURL": "", + "mapURL": "", + "latitude": 38.849, + "longitude": 111.608, + "agencies": null + } + ], + "id": 2, + "name": "Taiyuan, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 16, + "name": "Long March 4B", + "configuration": "B", + "familyname": "Long March 4", + "agencies": [ + { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + ], + "wikiURL": "http://en.wikipedia.org/wiki/Long_March_4B", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [], + "lsp": { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + }, + { + "id": 1432, + "name": "Soyuz 2.1b/Fregat | Meteor-M №2-2", + "windowstart": "July 5, 2019 05:41:00 UTC", + "windowend": "July 5, 2019 05:41:00 UTC", + "net": "July 5, 2019 05:41:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190705T054100Z", + "isoend": "20190705T054100Z", + "isonet": "20190705T054100Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-04 11:48:46", + "location": { + "pads": [ + { + "id": 170, + "name": "Cosmodrome Site 1S, Vostochny Cosmodrome, Siberia, Russian Federation", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Vostochny_Cosmodrome", + "mapURL": "https://www.google.ee/maps/place/51°53'03.8\"N+128°20'02.2\"E/", + "latitude": 51.884395, + "longitude": 128.333932, + "agencies": [ + { + "id": 63, + "name": "Russian Federal Space Agency (ROSCOSMOS)", + "abbrev": "RFSA", + "countryCode": "RUS", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Russian_Federal_Space_Agency", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://en.roscosmos.ru/", + "https://www.youtube.com/channel/UCOcpUgXosMCIlOsreUfNFiA", + "https://twitter.com/Roscosmos", + "https://www.facebook.com/Roscosmos" + ] + } + ] + } + ], + "id": 34, + "name": "Vostochny Cosmodrome, Siberia, Russian Federation", + "infoURL": "https://en.wikipedia.org/wiki/Vostochny_Cosmodrome", + "wikiURL": "", + "countryCode": "RUS" + }, + "rocket": { + "id": 65, + "name": "Soyuz 2.1b/Fregat", + "configuration": "2.1b/Fregat", + "familyname": "Soyuz", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Soyuz-2", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [], + "lsp": { + "id": 96, + "name": "Khrunichev State Research and Production Space Center", + "abbrev": "KhSC", + "countryCode": "RUS", + "type": 1, + "infoURL": "http://www.khrunichev.ru/main.php?lang=en", + "wikiURL": "http://en.wikipedia.org/wiki/Khrunichev_State_Research_and_Production_Space_Center", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.khrunichev.ru/main.php?lang=en" + ] + } + }, + { + "id": 1671, + "name": "Vega | Falcon Eye 1", + "windowstart": "July 6, 2019 01:53:00 UTC", + "windowend": "July 6, 2019 01:53:00 UTC", + "net": "July 6, 2019 01:53:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190706T015300Z", + "isoend": "20190706T015300Z", + "isonet": "20190706T015300Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-12 01:11:19", + "location": { + "pads": [ + { + "id": 18, + "name": "Ariane Launch Area 1, Kourou", + "infoURL": "http://www.esa.int/Our_Activities/Launchers/Europe_s_Spaceport/Europe_s_Spaceport2", + "wikiURL": "https://en.wikipedia.org/wiki/ELA-1", + "mapURL": "https://www.google.com/maps/?q=5.239,-52.775", + "latitude": 5.236, + "longitude": -52.775, + "agencies": [ + { + "id": 115, + "name": "Arianespace", + "abbrev": "ASA", + "countryCode": "FRA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Arianespace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.arianespace.com", + "https://www.youtube.com/channel/UCRn9F2D9j-t4A-HgudM7aLQ", + "https://www.facebook.com/ArianeGroup", + "https://twitter.com/arianespace", + "https://www.instagram.com/arianespace" + ] + } + ] + } + ], + "id": 3, + "name": "Kourou, French Guiana", + "infoURL": "", + "wikiURL": "", + "countryCode": "GUF" + }, + "rocket": { + "id": 18, + "name": "Vega", + "configuration": "", + "familyname": "Vega", + "agencies": [], + "wikiURL": "http://en.wikipedia.org/wiki/Vega_rocket", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 1169, + "name": "Falcon Eye 1", + "description": "Falcon Eye 1 is a high-resolution Earth-imaging satellite for the United Arab Emirates. Built by Airbus Defense and Space with an optical imaging payload from Thales Alenia Space, Falcon Eye 1 is the first of two surveillance satellites ordered by the UAE’s military.", + "type": 7, + "wikiURL": "", + "typeName": "Government/Top Secret", + "agencies": [], + "payloads": [] + } + ], + "lsp": { + "id": 115, + "name": "Arianespace", + "abbrev": "ASA", + "countryCode": "FRA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Arianespace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.arianespace.com", + "https://www.youtube.com/channel/UCRn9F2D9j-t4A-HgudM7aLQ", + "https://www.facebook.com/ArianeGroup", + "https://twitter.com/arianespace", + "https://www.instagram.com/arianespace" + ] + } + }, + { + "id": 1203, + "name": "Atlas V 551 | AEHF-5", + "windowstart": "July 9, 2019 12:27:00 UTC", + "windowend": "July 9, 2019 14:27:00 UTC", + "net": "July 9, 2019 12:27:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190709T122700Z", + "isoend": "20190709T142700Z", + "isonet": "20190709T122700Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [ + "https://www.ulalaunch.com/missions/atlas-v-aehf-5" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-23 14:01:16", + "location": { + "pads": [ + { + "id": 85, + "name": "Space Launch Complex 41, Cape Canaveral, FL", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Cape_Canaveral_Air_Force_Station_Space_Launch_Complex_41", + "mapURL": "http://maps.google.com/maps?q=28.58341025,-80.58303644", + "latitude": 28.58341025, + "longitude": -80.58303644, + "agencies": [] + } + ], + "id": 16, + "name": "Cape Canaveral, FL, USA", + "infoURL": "", + "wikiURL": "", + "countryCode": "USA" + }, + "rocket": { + "id": 37, + "name": "Atlas V 551", + "configuration": "551", + "familyname": "Atlas", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Atlas_V", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/Atlas+V+551_1920.jpg" + }, + "missions": [ + { + "id": 405, + "name": "AEHF-5", + "description": "This is the fifth satellite in the Advanced Extremely High Frequency (AEHF) system, which is a series of communications satellites operated by the United States Air Force Space Command. It provides global, survivable, protected communications capabilities for strategic command and tactical warfighters operating on ground, sea and air platforms.", + "type": 10, + "wikiURL": "https://en.wikipedia.org/wiki/Advanced_Extremely_High_Frequency", + "typeName": "Communications", + "agencies": [], + "payloads": [] + } + ], + "lsp": { + "id": 124, + "name": "United Launch Alliance", + "abbrev": "ULA", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/United_Launch_Alliance", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.ulalaunch.com/", + "https://www.youtube.com/channel/UCnrGPRKAg1PgvuSHrRIl3jg", + "https://twitter.com/ulalaunch", + "https://www.facebook.com/ulalaunch", + "https://www.instagram.com/ulalaunch/" + ] + } + }, + { + "id": 1112, + "name": "Proton-M/Blok DM-03 | Spektr-RG", + "windowstart": "July 12, 2019 00:00:00 UTC", + "windowend": "July 12, 2019 00:00:00 UTC", + "net": "July 12, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190712T000000Z", + "isoend": "20190712T000000Z", + "isonet": "20190712T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-21 14:28:53", + "location": { + "pads": [ + { + "id": 30, + "name": "31/6, Baikonur Cosmodrome, Kazakhstan", + "infoURL": "", + "wikiURL": "", + "mapURL": "http://maps.google.com/maps?q=45.996+N,+63.564+E", + "latitude": 45.996034, + "longitude": 63.564003, + "agencies": [] + } + ], + "id": 10, + "name": "Baikonur Cosmodrome, Republic of Kazakhstan", + "infoURL": "", + "wikiURL": "", + "countryCode": "KAZ" + }, + "rocket": { + "id": 62, + "name": "Proton-M/Blok DM-03", + "configuration": "-M/Blok DM-03", + "familyname": "Proton / UR-500", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Proton-M", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 748, + "name": "Spektr-RG", + "description": "Spektr-RG is a joint Russian-German observatory-class mission. It is intented to study the interplanetary magnetic field, galaxies and black holes.", + "type": 3, + "wikiURL": "https://en.wikipedia.org/wiki/Spektr-RG", + "typeName": "Astrophysics", + "agencies": [], + "payloads": [ + { + "id": 109, + "name": "Spektr-RG" + } + ] + } + ], + "lsp": { + "id": 96, + "name": "Khrunichev State Research and Production Space Center", + "abbrev": "KhSC", + "countryCode": "RUS", + "type": 1, + "infoURL": "http://www.khrunichev.ru/main.php?lang=en", + "wikiURL": "http://en.wikipedia.org/wiki/Khrunichev_State_Research_and_Production_Space_Center", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.khrunichev.ru/main.php?lang=en" + ] + } + }, + { + "id": 1133, + "name": "GSLV Mk III | Chandrayaan-2", + "windowstart": "July 14, 2019 21:21:00 UTC", + "windowend": "July 14, 2019 21:21:00 UTC", + "net": "July 14, 2019 21:21:00 UTC", + "wsstamp": 1563139260, + "westamp": 1563139260, + "netstamp": 1563139260, + "isostart": "20190714T212100Z", + "isoend": "20190714T212100Z", + "isonet": "20190714T212100Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [ + "https://en.wikipedia.org/wiki/Chandrayaan-2", + "http://www.isro.gov.in/chandrayaan-2" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-12 13:37:40", + "location": { + "pads": [ + { + "id": 145, + "name": "Satish Dhawan Space Centre Second Launch Pad", + "infoURL": "https://en.wikipedia.org/wiki/Satish_Dhawan_Space_Centre_Second_Launch_Pad", + "wikiURL": "https://en.wikipedia.org/wiki/Satish_Dhawan_Space_Centre_Second_Launch_Pad", + "mapURL": "https://www.google.com/maps?q=13.7199,80.2304", + "latitude": 13.7199, + "longitude": 80.2304, + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ] + } + ], + "id": 5, + "name": "Sriharikota, Republic of India", + "infoURL": "", + "wikiURL": "", + "countryCode": "IND" + }, + "rocket": { + "id": 85, + "name": "GSLV Mk III", + "configuration": "Mk III", + "familyname": "GSLV", + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Geosynchronous_Satellite_Launch_Vehicle_Mk_III", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 657, + "name": "Chandrayaan-2", + "description": "Chandrayaan-2 is India's second mission to the Moon. It consists of an orbiter, lander and rover. After reaching the 100 km lunar orbit, the lander housing the rover will separate from the orbiter. After a controlled descent, the lander will perform a soft landing on the lunar surface at a specified site and deploy the rover. Six-wheeled rover weighs around 20 kg and will operate on solar power. It will move around the landing site, performing lunar surface chemical analysis and relaying data back to Earth through the orbiter. The lander will be collecting data on Moon-quakes, thermal properties of the lunar surface, the density and variation of lunar surface plasma. The orbiter will be mapping lunar surface. Altogether, Chandrayaan-2 mission will collect scientific information on lunar topography, mineralogy, elemental abundance, lunar exosphere and signatures of hydroxyl and water-ice.", + "type": 2, + "wikiURL": "https://en.wikipedia.org/wiki/Chandrayaan-2", + "typeName": "Planetary Science", + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ], + "payloads": [ + { + "id": 5, + "name": "Orbiter" + }, + { + "id": 6, + "name": "Vikram Lander" + }, + { + "id": 7, + "name": "Pragyan Rover" + } + ] + } + ], + "lsp": { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + } + ], + "total": 201, + "offset": 0, + "count": 10 +} diff --git a/features/detail/src/main/AndroidManifest.xml b/features/detail/src/main/AndroidManifest.xml new file mode 100644 index 0000000..07efa7a --- /dev/null +++ b/features/detail/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + diff --git a/features/detail/src/main/kotlin/com/melih/detail/di/DetailContributor.kt b/features/detail/src/main/kotlin/com/melih/detail/di/DetailContributor.kt new file mode 100644 index 0000000..22a2c54 --- /dev/null +++ b/features/detail/src/main/kotlin/com/melih/detail/di/DetailContributor.kt @@ -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 +} diff --git a/features/detail/src/main/kotlin/com/melih/detail/di/modules/DetailBinds.kt b/features/detail/src/main/kotlin/com/melih/detail/di/modules/DetailBinds.kt new file mode 100644 index 0000000..578d247 --- /dev/null +++ b/features/detail/src/main/kotlin/com/melih/detail/di/modules/DetailBinds.kt @@ -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 +} diff --git a/features/detail/src/main/kotlin/com/melih/detail/ui/DetailActivity.kt b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailActivity.kt new file mode 100644 index 0000000..519c20e --- /dev/null +++ b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailActivity.kt @@ -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() { + + // 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 +} diff --git a/features/detail/src/main/kotlin/com/melih/detail/ui/DetailFragment.kt b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailFragment.kt new file mode 100644 index 0000000..204ebbf --- /dev/null +++ b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailFragment.kt @@ -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() { + + // 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 +} diff --git a/features/detail/src/main/kotlin/com/melih/detail/ui/DetailViewModel.kt b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailViewModel.kt new file mode 100644 index 0000000..ad17cbb --- /dev/null +++ b/features/detail/src/main/kotlin/com/melih/detail/ui/DetailViewModel.kt @@ -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() { + + // 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 +} diff --git a/features/detail/src/main/res/layout/activity_detail.xml b/features/detail/src/main/res/layout/activity_detail.xml new file mode 100644 index 0000000..89c60f2 --- /dev/null +++ b/features/detail/src/main/res/layout/activity_detail.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/features/detail/src/main/res/layout/fragment_detail.xml b/features/detail/src/main/res/layout/fragment_detail.xml new file mode 100644 index 0000000..abf4e90 --- /dev/null +++ b/features/detail/src/main/res/layout/fragment_detail.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/features/detail/src/main/res/navigation/nav_detail.xml b/features/detail/src/main/res/navigation/nav_detail.xml new file mode 100644 index 0000000..2f2f844 --- /dev/null +++ b/features/detail/src/main/res/navigation/nav_detail.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/features/detail/src/main/res/values/strings.xml b/features/detail/src/main/res/values/strings.xml new file mode 100644 index 0000000..c9dc1ae --- /dev/null +++ b/features/detail/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Image of the rocket + diff --git a/features/detail/src/test/java/com/melih/detail/BaseTestWithMainThread.kt b/features/detail/src/test/java/com/melih/detail/BaseTestWithMainThread.kt new file mode 100644 index 0000000..94e3614 --- /dev/null +++ b/features/detail/src/test/java/com/melih/detail/BaseTestWithMainThread.kt @@ -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() + } +} diff --git a/features/detail/src/test/java/com/melih/detail/DetailViewModelTest.kt b/features/detail/src/test/java/com/melih/detail/DetailViewModelTest.kt new file mode 100644 index 0000000..0e6a4d4 --- /dev/null +++ b/features/detail/src/test/java/com/melih/detail/DetailViewModelTest.kt @@ -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() + + 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 + } + } +} diff --git a/features/list/.gitignore b/features/list/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/features/list/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/list/build.gradle b/features/list/build.gradle new file mode 100644 index 0000000..df7af5b --- /dev/null +++ b/features/list/build.gradle @@ -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 +} diff --git a/features/list/consumer-rules.pro b/features/list/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/list/proguard-rules.pro b/features/list/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/features/list/proguard-rules.pro @@ -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 diff --git a/features/list/sampledata/launches.json b/features/list/sampledata/launches.json new file mode 100644 index 0000000..3e8569c --- /dev/null +++ b/features/list/sampledata/launches.json @@ -0,0 +1,1239 @@ +{ + "launches": [ + { + "id": 1946, + "name": "Long March 3B/E | Beidou-3 IGSO-2", + "windowstart": "June 24, 2019 17:52:00 UTC", + "windowend": "June 24, 2019 18:28:00 UTC", + "net": "June 24, 2019 17:52:00 UTC", + "wsstamp": 1561398720, + "westamp": 1561400880, + "netstamp": 1561398720, + "isostart": "20190624T175200Z", + "isoend": "20190624T182800Z", + "isonet": "20190624T175200Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-21 07:01:11", + "location": { + "pads": [ + { + "id": 143, + "name": "Launch Complex 3 ( LC-3 ) ( LA-1 ), Xichang Satellite Launch Center", + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/Xichang_Satellite_Launch_Center", + "mapURL": "https://www.google.com/maps/?q=28.246017,102.026556", + "latitude": 28.246017, + "longitude": 102.026556, + "agencies": [ + { + "id": 17, + "name": "China National Space Administration", + "abbrev": "CNSA", + "countryCode": "CHN", + "type": 1, + "infoURL": "http://www.cnsa.gov.cn/", + "wikiURL": "http://en.wikipedia.org/wiki/China_National_Space_Administration", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.cnsa.gov.cn/" + ] + } + ] + } + ], + "id": 25, + "name": "Xichang Satellite Launch Center, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 69, + "name": "Long March 3B/E", + "configuration": "B/E", + "familyname": "Long March 3", + "agencies": [ + { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Long_March_3B", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/LongMarch3BE.jpg_1024.jpg" + }, + "missions": [ + { + "id": 1216, + "name": "Beidou-3 IGSO-2", + "description": "These two satellites will be used to provide global navigation coverage as part of the Chinese Beidou (Compass) satellite navigation system.", + "type": 15, + "wikiURL": "https://en.wikipedia.org/wiki/BeiDou_Navigation_Satellite_System", + "typeName": "Navigation", + "agencies": null, + "payloads": [] + } + ], + "lsp": { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + }, + { + "id": 318, + "name": "Falcon Heavy | STP-2", + "windowstart": "June 25, 2019 03:30:00 UTC", + "windowend": "June 25, 2019 07:30:00 UTC", + "net": "June 25, 2019 03:30:00 UTC", + "wsstamp": 1561433400, + "westamp": 1561447800, + "netstamp": 1561433400, + "isostart": "20190625T033000Z", + "isoend": "20190625T073000Z", + "isonet": "20190625T033000Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [ + "http://www.spacex.com/webcast/" + ], + "vidURL": null, + "infoURLs": [ + "https://www.spacex.com/stp-2" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": 70, + "hashtag": null, + "changed": "2019-06-21 16:24:03", + "location": { + "pads": [ + { + "id": 87, + "name": "Launch Complex 39A, Kennedy Space Center, FL", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Kennedy_Space_Center_Launch_Complex_39#Launch_Pad_39A", + "mapURL": "http://maps.google.com/maps?q=28.608+N,+80.604+W", + "latitude": 28.60822681, + "longitude": -80.60428186, + "agencies": [] + } + ], + "id": 17, + "name": "Kennedy Space Center, FL, USA", + "infoURL": "", + "wikiURL": "", + "countryCode": "USA" + }, + "rocket": { + "id": 58, + "name": "Falcon Heavy", + "configuration": "Heavy", + "familyname": "Falcon", + "agencies": [ + { + "id": 121, + "name": "SpaceX", + "abbrev": "SpX", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/SpaceX", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.spacex.com/", + "https://twitter.com/SpaceX", + "https://www.youtube.com/channel/UCtI0Hodo5o5dUb67FeUjDeA" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Falcon_Heavy", + "infoURLs": [ + "http://www.spacex.com/falcon-heavy" + ], + "infoURL": "http://www.spacex.com/falcon-heavy", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920, + 2560 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/FalconHeavy.jpg_2560.jpg" + }, + "missions": [ + { + "id": 687, + "name": "STP-2", + "description": "The STP-2 payload is composed of 25 small spacecraft. Included is COSMIC-2 constellation to provide radio occultation data, along with 8 cubesat nanosatellites. \nOther payloads include LightSail carried by the Prox-1 nanosatellite, Oculus-ASR nanosatellite, GPIM and the Deep Space Atomic Clock.", + "type": 14, + "wikiURL": "https://en.wikipedia.org/wiki/Space_Test_Program", + "typeName": "Dedicated Rideshare", + "agencies": [ + { + "id": 161, + "name": "United States Air Force", + "abbrev": "USAF", + "countryCode": "USA", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/United_States_Air_Force", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.af.mil", + "https://www.facebook.com/USairforce", + "https://twitter.com/USairforce", + "https://www.youtube.com/afbluetube", + "https://www.instagram.com/usairforce", + "https://www.flickr.com/photos/usairforce" + ] + } + ], + "payloads": [ + { + "id": 444, + "name": "DSX (Demonstration & Science Experiments)" + }, + { + "id": 445, + "name": "Formosat-7/Cosmic-2" + }, + { + "id": 446, + "name": "GPIM (Green Propellant Infusion Mission)" + }, + { + "id": 447, + "name": "OTB-1" + }, + { + "id": 448, + "name": "Oculus-ASR" + }, + { + "id": 449, + "name": "NPSAT1" + }, + { + "id": 450, + "name": "Prox-1" + }, + { + "id": 451, + "name": "Lightsail-B" + }, + { + "id": 452, + "name": "E-TBex A, B" + }, + { + "id": 453, + "name": "PSAT-2 (ParkinsonSAT-2)" + }, + { + "id": 454, + "name": "TEPCE 1, 2" + }, + { + "id": 455, + "name": "CP 9 (LEO)" + }, + { + "id": 456, + "name": "StangSat" + } + ] + } + ], + "lsp": { + "id": 121, + "name": "SpaceX", + "abbrev": "SpX", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/SpaceX", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.spacex.com/", + "https://twitter.com/SpaceX", + "https://www.youtube.com/channel/UCtI0Hodo5o5dUb67FeUjDeA" + ] + } + }, + { + "id": 1932, + "name": "Electron | Make It Rain", + "windowstart": "June 27, 2019 04:30:00 UTC", + "windowend": "June 27, 2019 06:30:00 UTC", + "net": "June 27, 2019 04:30:00 UTC", + "wsstamp": 1561609800, + "westamp": 1561617000, + "netstamp": 1561609800, + "isostart": "20190627T043000Z", + "isoend": "20190627T063000Z", + "isonet": "20190627T043000Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-17 07:50:23", + "location": { + "pads": [ + { + "id": 166, + "name": "Rocket Lab Launch Complex 1", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Rocket_Lab_Launch_Complex_1", + "mapURL": "https://www.google.ee/maps/place/39°15'46.2\"S+177°51'52.1\"E/", + "latitude": -39.262833, + "longitude": 177.864469, + "agencies": [ + { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + ] + } + ], + "id": 40, + "name": "Onenui Station, Mahia Peninsula, New Zealand", + "infoURL": "", + "wikiURL": "", + "countryCode": "NZL" + }, + "rocket": { + "id": 148, + "name": "Electron", + "configuration": "", + "familyname": "Electron", + "agencies": [ + { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Rocket_Lab#Electron_Launch_Vehicle", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/Electron.jpg_1440.jpg" + }, + "missions": [ + { + "id": 1209, + "name": "Make It Rain", + "description": "Rideshare mission for Spaceflight. The mission is named \"Make it Rain\" in a nod to the high volume of rainfall in Seattle, where Spaceflight is headquartered, as well in New Zealand where Launch Complex 1 is located. Among the satellites on the mission for Spaceflight are BlackSky’s Global-4, two U.S. Special Operations Command (USSOCOM) Prometheus and Melbourne Space Program’s ACRUX-1.", + "type": 14, + "wikiURL": "", + "typeName": "Dedicated Rideshare", + "agencies": null, + "payloads": [ + { + "id": 457, + "name": "BlackSky Global 3" + }, + { + "id": 458, + "name": "ACRUX 1" + }, + { + "id": 459, + "name": "SpaceBEE 8" + }, + { + "id": 460, + "name": "SpaceBEE 9" + }, + { + "id": 461, + "name": "Prometheus-2 5,6" + } + ] + } + ], + "lsp": { + "id": 147, + "name": "Rocket Lab Ltd", + "abbrev": "RL", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Rocket_Lab", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.rocketlabusa.com/", + "https://twitter.com/rocketlab", + "https://www.youtube.com/user/RocketLabNZ", + "https://www.facebook.com/RocketLabUSA", + "https://www.linkedin.com/company/rocket-lab-limited" + ] + } + }, + { + "id": 1937, + "name": "Kuaizhou-11 | Maiden Flight", + "windowstart": "June 30, 2019 00:00:00 UTC", + "windowend": "June 30, 2019 00:00:00 UTC", + "net": "June 30, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190630T000000Z", + "isoend": "20190630T000000Z", + "isonet": "20190630T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-06 17:15:06", + "location": { + "pads": [ + { + "id": 115, + "name": "Unknown Pad, Jiuquan", + "infoURL": "", + "wikiURL": "", + "mapURL": "", + "latitude": 40.958, + "longitude": 100.291, + "agencies": null + } + ], + "id": 1, + "name": "Jiuquan, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 223, + "name": "Kuaizhou-11", + "configuration": "11", + "familyname": "Kuaizhou", + "agencies": null, + "wikiURL": "https://en.wikipedia.org/wiki/Kuaizhou#Models", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 1212, + "name": "Maiden Flight", + "description": "First flight of the new solid launcher developed by ExPace, subsidiary of CASIC. It will carry 2 communication satellites on this launch.", + "type": 13, + "wikiURL": "", + "typeName": "Test Flight", + "agencies": null, + "payloads": [] + } + ], + "lsp": { + "id": 194, + "name": "ExPace", + "abbrev": "EP", + "countryCode": "CHN", + "type": 3, + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/ExPace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [] + } + }, + { + "id": 1137, + "name": "Long March 4B | CBERS-4A", + "windowstart": "July 1, 2019 00:00:00 UTC", + "windowend": "July 1, 2019 00:00:00 UTC", + "net": "July 1, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190701T000000Z", + "isoend": "20190701T000000Z", + "isonet": "20190701T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2017-02-21 00:00:00", + "location": { + "pads": [ + { + "id": 116, + "name": "Unknown Pad, Taiyuan", + "infoURL": "", + "wikiURL": "", + "mapURL": "", + "latitude": 38.849, + "longitude": 111.608, + "agencies": null + } + ], + "id": 2, + "name": "Taiyuan, People's Republic of China", + "infoURL": "", + "wikiURL": "", + "countryCode": "CHN" + }, + "rocket": { + "id": 16, + "name": "Long March 4B", + "configuration": "B", + "familyname": "Long March 4", + "agencies": [ + { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + ], + "wikiURL": "http://en.wikipedia.org/wiki/Long_March_4B", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [], + "lsp": { + "id": 88, + "name": "China Aerospace Science and Technology Corporation", + "abbrev": "CASC", + "countryCode": "CHN", + "type": 1, + "infoURL": null, + "wikiURL": "https://en.wikipedia.org/wiki/China_Aerospace_Science_and_Technology_Corporation", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://english.spacechina.com/", + "http://www.cast.cn/item/list.asp?id=1561" + ] + } + }, + { + "id": 1432, + "name": "Soyuz 2.1b/Fregat | Meteor-M №2-2", + "windowstart": "July 5, 2019 05:41:00 UTC", + "windowend": "July 5, 2019 05:41:00 UTC", + "net": "July 5, 2019 05:41:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190705T054100Z", + "isoend": "20190705T054100Z", + "isonet": "20190705T054100Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-04 11:48:46", + "location": { + "pads": [ + { + "id": 170, + "name": "Cosmodrome Site 1S, Vostochny Cosmodrome, Siberia, Russian Federation", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Vostochny_Cosmodrome", + "mapURL": "https://www.google.ee/maps/place/51°53'03.8\"N+128°20'02.2\"E/", + "latitude": 51.884395, + "longitude": 128.333932, + "agencies": [ + { + "id": 63, + "name": "Russian Federal Space Agency (ROSCOSMOS)", + "abbrev": "RFSA", + "countryCode": "RUS", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Russian_Federal_Space_Agency", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://en.roscosmos.ru/", + "https://www.youtube.com/channel/UCOcpUgXosMCIlOsreUfNFiA", + "https://twitter.com/Roscosmos", + "https://www.facebook.com/Roscosmos" + ] + } + ] + } + ], + "id": 34, + "name": "Vostochny Cosmodrome, Siberia, Russian Federation", + "infoURL": "https://en.wikipedia.org/wiki/Vostochny_Cosmodrome", + "wikiURL": "", + "countryCode": "RUS" + }, + "rocket": { + "id": 65, + "name": "Soyuz 2.1b/Fregat", + "configuration": "2.1b/Fregat", + "familyname": "Soyuz", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Soyuz-2", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [], + "lsp": { + "id": 96, + "name": "Khrunichev State Research and Production Space Center", + "abbrev": "KhSC", + "countryCode": "RUS", + "type": 1, + "infoURL": "http://www.khrunichev.ru/main.php?lang=en", + "wikiURL": "http://en.wikipedia.org/wiki/Khrunichev_State_Research_and_Production_Space_Center", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.khrunichev.ru/main.php?lang=en" + ] + } + }, + { + "id": 1671, + "name": "Vega | Falcon Eye 1", + "windowstart": "July 6, 2019 01:53:00 UTC", + "windowend": "July 6, 2019 01:53:00 UTC", + "net": "July 6, 2019 01:53:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190706T015300Z", + "isoend": "20190706T015300Z", + "isonet": "20190706T015300Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-12 01:11:19", + "location": { + "pads": [ + { + "id": 18, + "name": "Ariane Launch Area 1, Kourou", + "infoURL": "http://www.esa.int/Our_Activities/Launchers/Europe_s_Spaceport/Europe_s_Spaceport2", + "wikiURL": "https://en.wikipedia.org/wiki/ELA-1", + "mapURL": "https://www.google.com/maps/?q=5.239,-52.775", + "latitude": 5.236, + "longitude": -52.775, + "agencies": [ + { + "id": 115, + "name": "Arianespace", + "abbrev": "ASA", + "countryCode": "FRA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Arianespace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.arianespace.com", + "https://www.youtube.com/channel/UCRn9F2D9j-t4A-HgudM7aLQ", + "https://www.facebook.com/ArianeGroup", + "https://twitter.com/arianespace", + "https://www.instagram.com/arianespace" + ] + } + ] + } + ], + "id": 3, + "name": "Kourou, French Guiana", + "infoURL": "", + "wikiURL": "", + "countryCode": "GUF" + }, + "rocket": { + "id": 18, + "name": "Vega", + "configuration": "", + "familyname": "Vega", + "agencies": [], + "wikiURL": "http://en.wikipedia.org/wiki/Vega_rocket", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 1169, + "name": "Falcon Eye 1", + "description": "Falcon Eye 1 is a high-resolution Earth-imaging satellite for the United Arab Emirates. Built by Airbus Defense and Space with an optical imaging payload from Thales Alenia Space, Falcon Eye 1 is the first of two surveillance satellites ordered by the UAE’s military.", + "type": 7, + "wikiURL": "", + "typeName": "Government/Top Secret", + "agencies": [], + "payloads": [] + } + ], + "lsp": { + "id": 115, + "name": "Arianespace", + "abbrev": "ASA", + "countryCode": "FRA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Arianespace", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.arianespace.com", + "https://www.youtube.com/channel/UCRn9F2D9j-t4A-HgudM7aLQ", + "https://www.facebook.com/ArianeGroup", + "https://twitter.com/arianespace", + "https://www.instagram.com/arianespace" + ] + } + }, + { + "id": 1203, + "name": "Atlas V 551 | AEHF-5", + "windowstart": "July 9, 2019 12:27:00 UTC", + "windowend": "July 9, 2019 14:27:00 UTC", + "net": "July 9, 2019 12:27:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190709T122700Z", + "isoend": "20190709T142700Z", + "isonet": "20190709T122700Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [ + "https://www.ulalaunch.com/missions/atlas-v-aehf-5" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-23 14:01:16", + "location": { + "pads": [ + { + "id": 85, + "name": "Space Launch Complex 41, Cape Canaveral, FL", + "infoURL": "", + "wikiURL": "https://en.wikipedia.org/wiki/Cape_Canaveral_Air_Force_Station_Space_Launch_Complex_41", + "mapURL": "http://maps.google.com/maps?q=28.58341025,-80.58303644", + "latitude": 28.58341025, + "longitude": -80.58303644, + "agencies": [] + } + ], + "id": 16, + "name": "Cape Canaveral, FL, USA", + "infoURL": "", + "wikiURL": "", + "countryCode": "USA" + }, + "rocket": { + "id": 37, + "name": "Atlas V 551", + "configuration": "551", + "familyname": "Atlas", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Atlas_V", + "infoURLs": [], + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/Atlas+V+551_1920.jpg" + }, + "missions": [ + { + "id": 405, + "name": "AEHF-5", + "description": "This is the fifth satellite in the Advanced Extremely High Frequency (AEHF) system, which is a series of communications satellites operated by the United States Air Force Space Command. It provides global, survivable, protected communications capabilities for strategic command and tactical warfighters operating on ground, sea and air platforms.", + "type": 10, + "wikiURL": "https://en.wikipedia.org/wiki/Advanced_Extremely_High_Frequency", + "typeName": "Communications", + "agencies": [], + "payloads": [] + } + ], + "lsp": { + "id": 124, + "name": "United Launch Alliance", + "abbrev": "ULA", + "countryCode": "USA", + "type": 3, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/United_Launch_Alliance", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.ulalaunch.com/", + "https://www.youtube.com/channel/UCnrGPRKAg1PgvuSHrRIl3jg", + "https://twitter.com/ulalaunch", + "https://www.facebook.com/ulalaunch", + "https://www.instagram.com/ulalaunch/" + ] + } + }, + { + "id": 1112, + "name": "Proton-M/Blok DM-03 | Spektr-RG", + "windowstart": "July 12, 2019 00:00:00 UTC", + "windowend": "July 12, 2019 00:00:00 UTC", + "net": "July 12, 2019 00:00:00 UTC", + "wsstamp": 0, + "westamp": 0, + "netstamp": 0, + "isostart": "20190712T000000Z", + "isoend": "20190712T000000Z", + "isonet": "20190712T000000Z", + "status": 2, + "inhold": 0, + "tbdtime": 1, + "vidURLs": [], + "vidURL": null, + "infoURLs": [], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 1, + "probability": -1, + "hashtag": null, + "changed": "2019-06-21 14:28:53", + "location": { + "pads": [ + { + "id": 30, + "name": "31/6, Baikonur Cosmodrome, Kazakhstan", + "infoURL": "", + "wikiURL": "", + "mapURL": "http://maps.google.com/maps?q=45.996+N,+63.564+E", + "latitude": 45.996034, + "longitude": 63.564003, + "agencies": [] + } + ], + "id": 10, + "name": "Baikonur Cosmodrome, Republic of Kazakhstan", + "infoURL": "", + "wikiURL": "", + "countryCode": "KAZ" + }, + "rocket": { + "id": 62, + "name": "Proton-M/Blok DM-03", + "configuration": "-M/Blok DM-03", + "familyname": "Proton / UR-500", + "agencies": [], + "wikiURL": "https://en.wikipedia.org/wiki/Proton-M", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 748, + "name": "Spektr-RG", + "description": "Spektr-RG is a joint Russian-German observatory-class mission. It is intented to study the interplanetary magnetic field, galaxies and black holes.", + "type": 3, + "wikiURL": "https://en.wikipedia.org/wiki/Spektr-RG", + "typeName": "Astrophysics", + "agencies": [], + "payloads": [ + { + "id": 109, + "name": "Spektr-RG" + } + ] + } + ], + "lsp": { + "id": 96, + "name": "Khrunichev State Research and Production Space Center", + "abbrev": "KhSC", + "countryCode": "RUS", + "type": 1, + "infoURL": "http://www.khrunichev.ru/main.php?lang=en", + "wikiURL": "http://en.wikipedia.org/wiki/Khrunichev_State_Research_and_Production_Space_Center", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "http://www.khrunichev.ru/main.php?lang=en" + ] + } + }, + { + "id": 1133, + "name": "GSLV Mk III | Chandrayaan-2", + "windowstart": "July 14, 2019 21:21:00 UTC", + "windowend": "July 14, 2019 21:21:00 UTC", + "net": "July 14, 2019 21:21:00 UTC", + "wsstamp": 1563139260, + "westamp": 1563139260, + "netstamp": 1563139260, + "isostart": "20190714T212100Z", + "isoend": "20190714T212100Z", + "isonet": "20190714T212100Z", + "status": 1, + "inhold": 0, + "tbdtime": 0, + "vidURLs": [], + "vidURL": null, + "infoURLs": [ + "https://en.wikipedia.org/wiki/Chandrayaan-2", + "http://www.isro.gov.in/chandrayaan-2" + ], + "infoURL": null, + "holdreason": null, + "failreason": null, + "tbddate": 0, + "probability": -1, + "hashtag": null, + "changed": "2019-06-12 13:37:40", + "location": { + "pads": [ + { + "id": 145, + "name": "Satish Dhawan Space Centre Second Launch Pad", + "infoURL": "https://en.wikipedia.org/wiki/Satish_Dhawan_Space_Centre_Second_Launch_Pad", + "wikiURL": "https://en.wikipedia.org/wiki/Satish_Dhawan_Space_Centre_Second_Launch_Pad", + "mapURL": "https://www.google.com/maps?q=13.7199,80.2304", + "latitude": 13.7199, + "longitude": 80.2304, + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ] + } + ], + "id": 5, + "name": "Sriharikota, Republic of India", + "infoURL": "", + "wikiURL": "", + "countryCode": "IND" + }, + "rocket": { + "id": 85, + "name": "GSLV Mk III", + "configuration": "Mk III", + "familyname": "GSLV", + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ], + "wikiURL": "https://en.wikipedia.org/wiki/Geosynchronous_Satellite_Launch_Vehicle_Mk_III", + "infoURLs": [], + "imageURL": "https://s3.amazonaws.com/launchlibrary/RocketImages/placeholder_1920.png", + "imageSizes": [ + 320, + 480, + 640, + 720, + 768, + 800, + 960, + 1024, + 1080, + 1280, + 1440, + 1920 + ] + }, + "missions": [ + { + "id": 657, + "name": "Chandrayaan-2", + "description": "Chandrayaan-2 is India's second mission to the Moon. It consists of an orbiter, lander and rover. After reaching the 100 km lunar orbit, the lander housing the rover will separate from the orbiter. After a controlled descent, the lander will perform a soft landing on the lunar surface at a specified site and deploy the rover. Six-wheeled rover weighs around 20 kg and will operate on solar power. It will move around the landing site, performing lunar surface chemical analysis and relaying data back to Earth through the orbiter. The lander will be collecting data on Moon-quakes, thermal properties of the lunar surface, the density and variation of lunar surface plasma. The orbiter will be mapping lunar surface. Altogether, Chandrayaan-2 mission will collect scientific information on lunar topography, mineralogy, elemental abundance, lunar exosphere and signatures of hydroxyl and water-ice.", + "type": 2, + "wikiURL": "https://en.wikipedia.org/wiki/Chandrayaan-2", + "typeName": "Planetary Science", + "agencies": [ + { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + ], + "payloads": [ + { + "id": 5, + "name": "Orbiter" + }, + { + "id": 6, + "name": "Vikram Lander" + }, + { + "id": 7, + "name": "Pragyan Rover" + } + ] + } + ], + "lsp": { + "id": 31, + "name": "Indian Space Research Organization", + "abbrev": "ISRO", + "countryCode": "IND", + "type": 1, + "infoURL": null, + "wikiURL": "http://en.wikipedia.org/wiki/Indian_Space_Research_Organization", + "changed": "2017-02-21 00:00:00", + "infoURLs": [ + "https://www.isro.gov.in/", + "https://twitter.com/ISRO", + "https://www.facebook.com/ISRO" + ] + } + } + ], + "total": 201, + "offset": 0, + "count": 10 +} diff --git a/features/list/src/main/AndroidManifest.xml b/features/list/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a50e1f6 --- /dev/null +++ b/features/list/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/features/list/src/main/kotlin/com/melih/list/di/LaunchesContributor.kt b/features/list/src/main/kotlin/com/melih/list/di/LaunchesContributor.kt new file mode 100644 index 0000000..a3065ee --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/di/LaunchesContributor.kt @@ -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 +} diff --git a/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesBinds.kt b/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesBinds.kt new file mode 100644 index 0000000..35a77a4 --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesBinds.kt @@ -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 +} diff --git a/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesProvides.kt b/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesProvides.kt new file mode 100644 index 0000000..22f91ea --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/di/modules/LaunchesProvides.kt @@ -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() +} diff --git a/features/list/src/main/kotlin/com/melih/list/ui/LaunchesActivity.kt b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesActivity.kt new file mode 100644 index 0000000..3043883 --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesActivity.kt @@ -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() { + + // 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 +} diff --git a/features/list/src/main/kotlin/com/melih/list/ui/LaunchesAdapter.kt b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesAdapter.kt new file mode 100644 index 0000000..a47c3e5 --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesAdapter.kt @@ -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( + object : DiffUtil.ItemCallback() { + 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 = + LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false)) + +} + +class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder(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() + } +} diff --git a/features/list/src/main/kotlin/com/melih/list/ui/LaunchesFragment.kt b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesFragment.kt new file mode 100644 index 0000000..bda4dc9 --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesFragment.kt @@ -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(), SwipeRefreshLayout.OnRefreshListener { + + // region Properties + + @ExperimentalCoroutinesApi + private val viewModel: LaunchesViewModel + get() = viewModelFactory.createFor(this) + + private val launchesAdapter = LaunchesAdapter(::onItemSelected) + private val itemList = mutableListOf() + // 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 +} diff --git a/features/list/src/main/kotlin/com/melih/list/ui/LaunchesViewModel.kt b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesViewModel.kt new file mode 100644 index 0000000..4f05f86 --- /dev/null +++ b/features/list/src/main/kotlin/com/melih/list/ui/LaunchesViewModel.kt @@ -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>() { + + // 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 +} diff --git a/features/list/src/main/res/anim/item_enter.xml b/features/list/src/main/res/anim/item_enter.xml new file mode 100644 index 0000000..209a1ac --- /dev/null +++ b/features/list/src/main/res/anim/item_enter.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/features/list/src/main/res/anim/layout_item_enter.xml b/features/list/src/main/res/anim/layout_item_enter.xml new file mode 100644 index 0000000..7c889a6 --- /dev/null +++ b/features/list/src/main/res/anim/layout_item_enter.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/features/list/src/main/res/layout/activity_launches.xml b/features/list/src/main/res/layout/activity_launches.xml new file mode 100644 index 0000000..4369e55 --- /dev/null +++ b/features/list/src/main/res/layout/activity_launches.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/features/list/src/main/res/layout/fragment_launches.xml b/features/list/src/main/res/layout/fragment_launches.xml new file mode 100644 index 0000000..5835a11 --- /dev/null +++ b/features/list/src/main/res/layout/fragment_launches.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + diff --git a/features/list/src/main/res/layout/row_launch.xml b/features/list/src/main/res/layout/row_launch.xml new file mode 100644 index 0000000..bf9fcaa --- /dev/null +++ b/features/list/src/main/res/layout/row_launch.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/features/list/src/main/res/menu/menu_rocket_list.xml b/features/list/src/main/res/menu/menu_rocket_list.xml new file mode 100644 index 0000000..6932d9a --- /dev/null +++ b/features/list/src/main/res/menu/menu_rocket_list.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/features/list/src/main/res/navigation/nav_launches.xml b/features/list/src/main/res/navigation/nav_launches.xml new file mode 100644 index 0000000..03ee663 --- /dev/null +++ b/features/list/src/main/res/navigation/nav_launches.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/features/list/src/main/res/values/integers.xml b/features/list/src/main/res/values/integers.xml new file mode 100644 index 0000000..d20544d --- /dev/null +++ b/features/list/src/main/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 350 + \ No newline at end of file diff --git a/features/list/src/main/res/values/strings.xml b/features/list/src/main/res/values/strings.xml new file mode 100644 index 0000000..848414d --- /dev/null +++ b/features/list/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Image of the rocket + Search + diff --git a/features/list/src/test/kotlin/com/melih/list/BaseTestWithMainThread.kt b/features/list/src/test/kotlin/com/melih/list/BaseTestWithMainThread.kt new file mode 100644 index 0000000..94e3614 --- /dev/null +++ b/features/list/src/test/kotlin/com/melih/list/BaseTestWithMainThread.kt @@ -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() + } +} diff --git a/features/list/src/test/kotlin/com/melih/list/LaunchesViewModelTest.kt b/features/list/src/test/kotlin/com/melih/list/LaunchesViewModelTest.kt new file mode 100644 index 0000000..7a28959 --- /dev/null +++ b/features/list/src/test/kotlin/com/melih/list/LaunchesViewModelTest.kt @@ -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) } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..70e38b1 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..944f2c1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/repository/.gitignore b/repository/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/repository/.gitignore @@ -0,0 +1 @@ +/build diff --git a/repository/build.gradle b/repository/build.gradle new file mode 100644 index 0000000..27970fb --- /dev/null +++ b/repository/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +apply from: "$rootProject.projectDir/scripts/module.gradle" + +android { + defaultConfig { + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$rootProject.projectDir/reports/room".toString()] + } + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation libraries.coroutines + implementation libraries.retrofit + implementation libraries.room + implementation libraries.moshiKotlin + implementation libraries.okHttpLogger + + kapt annotationProcessors.roomCompiler + + testImplementation testLibraries.coroutinesCore + testImplementation testLibraries.coroutinesTest +} diff --git a/repository/proguard-rules.pro b/repository/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/AndroidManifest.xml b/repository/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1af640d --- /dev/null +++ b/repository/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/repository/src/main/kotlin/com/melih/repository/Constants.kt b/repository/src/main/kotlin/com/melih/repository/Constants.kt new file mode 100644 index 0000000..05de778 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/Constants.kt @@ -0,0 +1,5 @@ +package com.melih.repository + +const val DEFAULT_NAME = "Default name" +const val EMPTY_STRING = "" +const val DEFAULT_IMAGE_SIZE = 480 diff --git a/repository/src/main/kotlin/com/melih/repository/Repository.kt b/repository/src/main/kotlin/com/melih/repository/Repository.kt new file mode 100644 index 0000000..297ac78 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/Repository.kt @@ -0,0 +1,13 @@ +package com.melih.repository + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Result + +/** + * Abstract class to create contract in sources to seperate low level business logic from build and return type + */ +abstract class Repository { + + internal abstract suspend fun getNextLaunches(count: Int): Result> + internal abstract suspend fun getLaunchById(id: Long): Result +} diff --git a/repository/src/main/kotlin/com/melih/repository/entities/LaunchEntity.kt b/repository/src/main/kotlin/com/melih/repository/entities/LaunchEntity.kt new file mode 100644 index 0000000..c7c3f27 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/entities/LaunchEntity.kt @@ -0,0 +1,17 @@ +package com.melih.repository.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.melih.repository.DEFAULT_NAME +import com.squareup.moshi.Json + +@Entity(tableName = "Launches") +data class LaunchEntity( + @PrimaryKey val id: Long = 0L, + val name: String = DEFAULT_NAME, + @field:Json(name = "wsstamp") val launchStartTime: Long = 0L, + @field:Json(name = "westamp") val launchEndTime: Long = 0L, + val location: LocationEntity = LocationEntity(), + val rocket: RocketEntity = RocketEntity(), + val missions: List = listOf(MissionEntity()) +) diff --git a/repository/src/main/kotlin/com/melih/repository/entities/LaunchesEntity.kt b/repository/src/main/kotlin/com/melih/repository/entities/LaunchesEntity.kt new file mode 100644 index 0000000..5119207 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/entities/LaunchesEntity.kt @@ -0,0 +1,9 @@ +package com.melih.repository.entities + +data class LaunchesEntity( + val id: Long = 0L, + val launches: List = listOf(), + val total: Int = 0, + val offset: Int = 0, + val count: Int = 0 +) diff --git a/repository/src/main/kotlin/com/melih/repository/entities/LocationEntity.kt b/repository/src/main/kotlin/com/melih/repository/entities/LocationEntity.kt new file mode 100644 index 0000000..13bf125 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/entities/LocationEntity.kt @@ -0,0 +1,17 @@ +package com.melih.repository.entities + +import androidx.room.ColumnInfo +import com.melih.repository.DEFAULT_NAME + +data class LocationEntity( + @ColumnInfo(name = "id_location") val id: Long = 0L, + @ColumnInfo(name = "name_location") val name: String = DEFAULT_NAME, + val pads: List = listOf(PadEntity()) +) + +data class PadEntity( + @ColumnInfo(name = "id_pad") val id: Long = 0L, + @ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME, + val lat: Long = 0L, + val long: Long = 0L +) diff --git a/repository/src/main/kotlin/com/melih/repository/entities/MissionEntity.kt b/repository/src/main/kotlin/com/melih/repository/entities/MissionEntity.kt new file mode 100644 index 0000000..ab641e8 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/entities/MissionEntity.kt @@ -0,0 +1,12 @@ +package com.melih.repository.entities + +import androidx.room.ColumnInfo +import com.melih.repository.DEFAULT_NAME +import com.melih.repository.EMPTY_STRING + +data class MissionEntity( + @ColumnInfo(name = "id_mission") val id: Long = 0L, + @ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME, + val description: String = EMPTY_STRING, + val typeName: String = EMPTY_STRING +) diff --git a/repository/src/main/kotlin/com/melih/repository/entities/RocketEntity.kt b/repository/src/main/kotlin/com/melih/repository/entities/RocketEntity.kt new file mode 100644 index 0000000..3f220e5 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/entities/RocketEntity.kt @@ -0,0 +1,14 @@ +package com.melih.repository.entities + +import androidx.room.ColumnInfo +import com.melih.repository.DEFAULT_NAME +import com.melih.repository.EMPTY_STRING +import com.squareup.moshi.Json + +data class RocketEntity( + @ColumnInfo(name = "id_rocket") val id: Long = 0L, + @ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME, + @field:Json(name = "familyname") val familyName: String = DEFAULT_NAME, + val imageSizes: IntArray = intArrayOf(), + val imageURL: String = EMPTY_STRING +) diff --git a/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunchDetails.kt b/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunchDetails.kt new file mode 100644 index 0000000..c38bffa --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunchDetails.kt @@ -0,0 +1,27 @@ +package com.melih.repository.interactors + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.BaseInteractor +import com.melih.repository.interactors.base.InteractorParameters +import com.melih.repository.interactors.base.Result +import com.melih.repository.sources.SourceManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import javax.inject.Inject + +/** + * Gets next given number of launches + */ +class GetLaunchDetails @Inject constructor( + private val sourceManager: SourceManager +) : BaseInteractor() { + + @ExperimentalCoroutinesApi + override suspend fun run(collector: FlowCollector>, params: Params) { + collector.emit(sourceManager.getLaunchById(params.id)) + } + + data class Params( + val id: Long + ) : InteractorParameters +} diff --git a/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunches.kt b/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunches.kt new file mode 100644 index 0000000..ae4aa43 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/interactors/GetLaunches.kt @@ -0,0 +1,27 @@ +package com.melih.repository.interactors + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.BaseInteractor +import com.melih.repository.interactors.base.InteractorParameters +import com.melih.repository.interactors.base.Result +import com.melih.repository.sources.SourceManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import javax.inject.Inject + +/** + * Gets next given number of launches + */ +class GetLaunches @Inject constructor( + private val sourceManager: SourceManager +) : BaseInteractor, GetLaunches.Params>() { + + @ExperimentalCoroutinesApi + override suspend fun run(collector: FlowCollector>>, params: Params) { + collector.emit(sourceManager.getNextLaunches(params.count)) + } + + data class Params( + val count: Int = 10 + ) : InteractorParameters +} diff --git a/repository/src/main/kotlin/com/melih/repository/interactors/base/BaseInteractor.kt b/repository/src/main/kotlin/com/melih/repository/interactors/base/BaseInteractor.kt new file mode 100644 index 0000000..e33ecfa --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/interactors/base/BaseInteractor.kt @@ -0,0 +1,41 @@ +package com.melih.repository.interactors.base + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn + +/** + * Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage. + */ +abstract class BaseInteractor { + + // region Abstractions + + @ExperimentalCoroutinesApi + protected abstract suspend fun run(collector: FlowCollector>, params: P) + // endregion + + // region Functions + + @ExperimentalCoroutinesApi + operator fun invoke(params: P) = + flow> { + emit(Result.State.Loading()) + run(this, params) + emit(Result.State.Loaded()) + }.flowOn(Dispatchers.IO) + // endregion +} + +/** + * Contract for parameter classes + */ +interface InteractorParameters + +/** + * Symbolizes absence of parameters for an [interactor][BaseInteractor] + */ +class None : Any(), InteractorParameters diff --git a/repository/src/main/kotlin/com/melih/repository/interactors/base/Reason.kt b/repository/src/main/kotlin/com/melih/repository/interactors/base/Reason.kt new file mode 100644 index 0000000..5431133 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/interactors/base/Reason.kt @@ -0,0 +1,19 @@ +package com.melih.repository.interactors.base + +import androidx.annotation.StringRes +import com.melih.repository.R + + +/** + * [Result.Failure] reasons + */ +sealed class Reason(@StringRes val messageRes: Int) { + + class NetworkError : Reason(R.string.reason_network) + class EmptyResultError : Reason(R.string.reason_empty_body) + class GenericError : Reason(R.string.reason_generic) + class ResponseError : Reason(R.string.reason_response) + class TimeoutError : Reason(R.string.reason_timeout) + class PersistenceEmpty : Reason(R.string.reason_persistance_empty) + class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty) +} diff --git a/repository/src/main/kotlin/com/melih/repository/interactors/base/Result.kt b/repository/src/main/kotlin/com/melih/repository/interactors/base/Result.kt new file mode 100644 index 0000000..c4d5306 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/interactors/base/Result.kt @@ -0,0 +1,45 @@ +package com.melih.repository.interactors.base + + +/** + * Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor] + */ +sealed class Result { + + // region Subclasses + + class Success(val successData: T) : Result() + class Failure(val errorData: Reason) : Result() + + sealed class State : Result() { + class Loading : State() + class Loaded : State() + } + // endregion + + // region Functions + + inline fun handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) { + when (this) { + is Success -> successBlock(successData) + is Failure -> failureBlock(errorData) + is State -> stateBlock(this) + } + } + + inline fun handleSuccess(successBlock: (T) -> Unit) { + if (this is Success) + successBlock(successData) + } + + inline fun handleFailure(errorBlock: (Reason) -> Unit) { + if (this is Failure) + errorBlock(errorData) + } + + inline fun handleState(stateBlock: (State) -> Unit) { + if (this is State) + stateBlock(this) + } + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/network/Api.kt b/repository/src/main/kotlin/com/melih/repository/network/Api.kt new file mode 100644 index 0000000..5ce9cad --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/network/Api.kt @@ -0,0 +1,19 @@ +package com.melih.repository.network + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.entities.LaunchesEntity +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Retrofit interface for networking + */ +interface Api { + + @GET("launch/next/{count}") + suspend fun getNextLaunches(@Path("count") count: Int): Response + + @GET("launch/{id}") + suspend fun getLaunchById(@Path("id") id: Long): Response +} diff --git a/repository/src/main/kotlin/com/melih/repository/network/ApiImpl.kt b/repository/src/main/kotlin/com/melih/repository/network/ApiImpl.kt new file mode 100644 index 0000000..3a03cac --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/network/ApiImpl.kt @@ -0,0 +1,43 @@ +package com.melih.repository.network + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.entities.LaunchesEntity +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Inject + +class ApiImpl @Inject constructor() : Api { + + // region Properties + + private val service by lazy { + val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + Retrofit.Builder() + .client( + OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor() + .setLevel(HttpLoggingInterceptor.Level.BODY) + ).build() + ) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .baseUrl("https://launchlibrary.net/1.4/") + .build() + .create(Api::class.java) + } + // endregion + + override suspend fun getNextLaunches(count: Int): Response = + service.getNextLaunches(count) + + override suspend fun getLaunchById(id: Long): Response = + service.getLaunchById(id) +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/LaunchesDatabase.kt b/repository/src/main/kotlin/com/melih/repository/persistence/LaunchesDatabase.kt new file mode 100644 index 0000000..304e2a8 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/LaunchesDatabase.kt @@ -0,0 +1,30 @@ +package com.melih.repository.persistence + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.persistence.converters.LocationConverter +import com.melih.repository.persistence.converters.MissionConverter +import com.melih.repository.persistence.converters.RocketConverter +import com.melih.repository.persistence.dao.LaunchesDao + +const val DB_NAME = "LaunchesDB" + +/** + * DB that manages launches + */ +@Database( + entities = [LaunchEntity::class], + exportSchema = true, + version = 1 +) +@TypeConverters( + LocationConverter::class, + RocketConverter::class, + MissionConverter::class +) +abstract class LaunchesDatabase : RoomDatabase() { + + abstract val launchesDao: LaunchesDao +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseConverter.kt b/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseConverter.kt new file mode 100644 index 0000000..735d615 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseConverter.kt @@ -0,0 +1,29 @@ +package com.melih.repository.persistence.converters + +import androidx.room.TypeConverter +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +/** + * Base converter for reduced boilerplate code + */ +abstract class BaseConverter { + + private val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + abstract fun getAdapter(moshi: Moshi): JsonAdapter + + // region Functions + + @TypeConverter + fun convertFrom(item: T) = + getAdapter(moshi).toJson(item) + + @TypeConverter + fun convertTo(string: String) = + getAdapter(moshi).fromJson(string) + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseListConverter.kt b/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseListConverter.kt new file mode 100644 index 0000000..f304c0a --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/converters/BaseListConverter.kt @@ -0,0 +1,29 @@ +package com.melih.repository.persistence.converters + +import androidx.room.TypeConverter +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory + +/** + * Base converter for reduced boilerplate code + */ +abstract class BaseListConverter { + + private val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + abstract fun getAdapter(moshi: Moshi): JsonAdapter> + + // region Functions + + @TypeConverter + fun convertFrom(items: List) = + getAdapter(moshi).toJson(items) + + @TypeConverter + fun convertTo(string: String): List? = + getAdapter(moshi).fromJson(string) + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/converters/LocationConverter.kt b/repository/src/main/kotlin/com/melih/repository/persistence/converters/LocationConverter.kt new file mode 100644 index 0000000..a05da1b --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/converters/LocationConverter.kt @@ -0,0 +1,13 @@ +package com.melih.repository.persistence.converters + +import com.melih.repository.entities.LocationEntity +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +/** + * Converts [location][LocationEntity] + */ +class LocationConverter : BaseConverter() { + override fun getAdapter(moshi: Moshi): JsonAdapter = + moshi.adapter(LocationEntity::class.java) +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/converters/MissionConverter.kt b/repository/src/main/kotlin/com/melih/repository/persistence/converters/MissionConverter.kt new file mode 100644 index 0000000..1d25e9a --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/converters/MissionConverter.kt @@ -0,0 +1,20 @@ +package com.melih.repository.persistence.converters + +import com.melih.repository.entities.MissionEntity +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types + +/** + * Converts [mission][MissionEntity] + */ +class MissionConverter : BaseListConverter() { + + override fun getAdapter(moshi: Moshi): JsonAdapter> = + moshi.adapter( + Types.newParameterizedType( + List::class.java, + MissionEntity::class.java + ) + ) +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/converters/RocketConverter.kt b/repository/src/main/kotlin/com/melih/repository/persistence/converters/RocketConverter.kt new file mode 100644 index 0000000..980d64d --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/converters/RocketConverter.kt @@ -0,0 +1,13 @@ +package com.melih.repository.persistence.converters + +import com.melih.repository.entities.RocketEntity +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi + +/** + * Converts [rocket][RocketEntity] + */ +class RocketConverter : BaseConverter() { + override fun getAdapter(moshi: Moshi): JsonAdapter = + moshi.adapter(RocketEntity::class.java) +} diff --git a/repository/src/main/kotlin/com/melih/repository/persistence/dao/LaunchesDao.kt b/repository/src/main/kotlin/com/melih/repository/persistence/dao/LaunchesDao.kt new file mode 100644 index 0000000..97f12bb --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/persistence/dao/LaunchesDao.kt @@ -0,0 +1,44 @@ +package com.melih.repository.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import com.melih.repository.entities.LaunchEntity + +/** + * DAO for list of [launches][LaunchEntity] + */ +@Dao +abstract class LaunchesDao { + + // region Queries + + @Query("SELECT * FROM Launches LIMIT :count") + abstract suspend fun getLaunches(count: Int): List + + @Query("SELECT * FROM Launches WHERE id=:id LIMIT 1") + abstract suspend fun getLaunchById(id: Long): LaunchEntity? + + @Query("DELETE FROM Launches") + abstract suspend fun nukeLaunches() + // endregion + + // region Insertion + + @Insert + abstract suspend fun saveLaunches(launches: List) + + @Insert + abstract suspend fun saveLaunch(launch: LaunchEntity) + // endregion + + // region Transactions + + @Transaction + open suspend fun updateLaunches(launches: List) { + nukeLaunches() + saveLaunches(launches) + } + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/sources/NetworkSource.kt b/repository/src/main/kotlin/com/melih/repository/sources/NetworkSource.kt new file mode 100644 index 0000000..b64cd5c --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/sources/NetworkSource.kt @@ -0,0 +1,106 @@ +package com.melih.repository.sources + +import android.net.NetworkInfo +import com.melih.repository.DEFAULT_IMAGE_SIZE +import com.melih.repository.Repository +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import com.melih.repository.network.ApiImpl +import retrofit2.Response +import java.io.IOException +import javax.inject.Inject +import javax.inject.Provider + +/** + * NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository], + * returning either [failure][Result.Failure] with proper [reason][Reason] or [success][Result.Success] with data + */ +class NetworkSource @Inject constructor( + private val apiImpl: ApiImpl, + private val networkInfoProvider: Provider +) : Repository() { + // region Properties + + private val isNetworkConnected: Boolean + get() { + val networkInfo = networkInfoProvider.get() + return networkInfo != null && networkInfo.isConnected + } + // endregion + + // region Functions + + override suspend fun getNextLaunches(count: Int): Result> = + safeExecute(apiImpl::getNextLaunches, count) { entity -> + entity.launches.map { launch -> + if (!launch.rocket.imageURL.isNotBlank()) { + launch.copy( + rocket = launch.rocket.copy( + imageURL = transformImageUrl( + launch.rocket.imageURL, + launch.rocket.imageSizes + ) + ) + ) + } else { + launch + } + } + } + + override suspend fun getLaunchById(id: Long): Result = + safeExecute(apiImpl::getLaunchById, id) { + if (!it.rocket.imageURL.isNotBlank()) { + it.copy( + rocket = it.rocket.copy( + imageURL = transformImageUrl(it.rocket.imageURL, it.rocket.imageSizes) + ) + ) + } else { + it + } + } + + private suspend inline fun safeExecute( + block: suspend (param: P) -> Response, + param: P, + transform: (T) -> R + ) = + if (isNetworkConnected) { + try { + block(param).extractResponseBody(transform) + } catch (e: IOException) { + Result.Failure(Reason.TimeoutError()) + } + } else { + Result.Failure(Reason.NetworkError()) + } + + private inline fun Response.extractResponseBody(transform: (T) -> R) = + if (isSuccessful) { + body()?.let { + Result.Success(transform(it)) + } ?: Result.Failure(Reason.EmptyResultError()) + } else { + Result.Failure(Reason.ResponseError()) + } + + private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) = + try { + val urlSplit = imageUrl.split("_") + val url = urlSplit[0] + val format = urlSplit[1].split(".")[1] + + var requestedSize = DEFAULT_IMAGE_SIZE + + if (!supportedSizes.contains(requestedSize)) { + requestedSize = supportedSizes.last { it < requestedSize } + } + + "${url}_$requestedSize.$format" + } catch (e: Exception) { + imageUrl + } + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/sources/PersistenceSource.kt b/repository/src/main/kotlin/com/melih/repository/sources/PersistenceSource.kt new file mode 100644 index 0000000..6d6c974 --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/sources/PersistenceSource.kt @@ -0,0 +1,44 @@ +package com.melih.repository.sources + +import com.melih.repository.Repository +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import com.melih.repository.persistence.LaunchesDatabase +import javax.inject.Inject + +/** + * Persistance source using Room database to save / read objects for SST - offline usage + */ +class PersistenceSource @Inject constructor( + private val launchesDatabase: LaunchesDatabase +) : Repository() { + // region Functions + + override suspend fun getNextLaunches(count: Int): Result> = + launchesDatabase + .launchesDao + .getLaunches(count) + .takeIf { it.isNotEmpty() } + ?.run { + Result.Success(this) + } ?: Result.Failure(Reason.PersistenceEmpty()) + + override suspend fun getLaunchById(id: Long): Result = + launchesDatabase + .launchesDao + .getLaunchById(id) + .takeIf { it != null } + ?.run { + Result.Success(this) + } ?: Result.Failure(Reason.PersistenceEmpty()) + + internal suspend fun saveLaunches(launches: List) { + launchesDatabase.launchesDao.updateLaunches(launches) + } + + internal suspend fun saveLaunch(launch: LaunchEntity) { + launchesDatabase.launchesDao.saveLaunch(launch) + } + // endregion +} diff --git a/repository/src/main/kotlin/com/melih/repository/sources/SourceManager.kt b/repository/src/main/kotlin/com/melih/repository/sources/SourceManager.kt new file mode 100644 index 0000000..999225b --- /dev/null +++ b/repository/src/main/kotlin/com/melih/repository/sources/SourceManager.kt @@ -0,0 +1,55 @@ +package com.melih.repository.sources + +import com.melih.repository.Repository +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import javax.inject.Inject + +/** + * Manages SST by using network & persistance sources + */ +class SourceManager @Inject constructor( + private val networkSource: NetworkSource, + private val persistenceSource: PersistenceSource +) : Repository() { + + // region Functions + + override suspend fun getNextLaunches(count: Int): Result> { + networkSource + .getNextLaunches(count) + .takeIf { it is Result.Success } + ?.let { + persistenceSource.saveLaunches((it as Result.Success).successData) + } + + return persistenceSource + .getNextLaunches(count) + .takeIf { + it is Result.Success && it.successData.isNotEmpty() + } + ?: Result.Failure(Reason.NoNetworkPersistenceEmpty()) + } + + override suspend fun getLaunchById(id: Long): Result { + val result = + persistenceSource + .getLaunchById(id) + + return if (result is Result.Failure) { + networkSource + .getLaunchById(id) + .takeIf { it is Result.Success } + ?.let { + persistenceSource.saveLaunch((it as Result.Success).successData) + } + + persistenceSource + .getLaunchById(id) + } else { + result + } + } + // endregion +} diff --git a/repository/src/main/res/values/strings.xml b/repository/src/main/res/values/strings.xml new file mode 100644 index 0000000..92cdbc9 --- /dev/null +++ b/repository/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Network error + Response is empty + Something went wrong + Woops, seems we got a server error + Server timed out + There are no saved launches + Seems there are no data and network + diff --git a/repository/src/test/kotlin/com/melih/repository/interactors/base/BaseInteractorTest.kt b/repository/src/test/kotlin/com/melih/repository/interactors/base/BaseInteractorTest.kt new file mode 100644 index 0000000..f1d6430 --- /dev/null +++ b/repository/src/test/kotlin/com/melih/repository/interactors/base/BaseInteractorTest.kt @@ -0,0 +1,67 @@ +package com.melih.repository.interactors.base + +import io.mockk.coVerify +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldEqualTo +import org.junit.jupiter.api.Test +import java.util.* + + +class BaseInteractorTest { + + val testInteractor = spyk(TestInteractor()) + val testParams = TestParams() + + @Test + @ExperimentalCoroutinesApi + fun `BaseInteractor should send states and items emmited by run`() { + // Using run blocking due to threading problems in runBlockingTest + // See https://github.com/Kotlin/kotlinx.coroutines/issues/1204 + + runBlocking { + // Get result by invoking + val result = testInteractor(testParams) + + // Verify we invoked interactor exactly once + coVerify(exactly = 1) { testInteractor.invoke(any()) } + + // Verify result is type of Flow + result shouldBeInstanceOf Flow::class + + // This will actually collec the flow + val resultDeque = ArrayDeque>() + result.toCollection(resultDeque) + + // We sent exactly 3 items, verify size + resultDeque.size shouldEqualTo 3 + + // Verify first item is Loading state + resultDeque.poll() shouldBeInstanceOf Result.State.Loading::class + + // Verify second item is Success, with default value we set below in TestParams class + resultDeque.poll().also { + it shouldBeInstanceOf Result.Success::class + (it as Result.Success).successData shouldEqualTo 10 + } + + // Verify last item is Loaded state + resultDeque.poll() shouldBeInstanceOf Result.State.Loaded::class + } + } + + inner class TestInteractor : BaseInteractor() { + + @ExperimentalCoroutinesApi + override suspend fun run(collector: FlowCollector>, params: TestParams) { + collector.emit(Result.Success(params.testValue)) + } + } + + data class TestParams(val testValue: Int = 10) : InteractorParameters +} \ No newline at end of file diff --git a/repository/src/test/kotlin/com/melih/repository/interactors/base/ResultTest.kt b/repository/src/test/kotlin/com/melih/repository/interactors/base/ResultTest.kt new file mode 100644 index 0000000..79cfc02 --- /dev/null +++ b/repository/src/test/kotlin/com/melih/repository/interactors/base/ResultTest.kt @@ -0,0 +1,65 @@ +package com.melih.repository.interactors.base + +import com.melih.repository.R +import io.mockk.called +import io.mockk.spyk +import io.mockk.verify +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldEqualTo +import org.junit.jupiter.api.Test + +class ResultTest { + + private val number = 10 + + private val success = Result.Success(number) + private val failure = Result.Failure(Reason.GenericError()) + private val state = Result.State.Loading() + + private val emptyStateBlock = spyk({ _: Result.State -> }) + private val emptyFailureBlock = spyk({ _: Reason -> }) + private val emptySuccessBlock = spyk({ _: Int -> }) + + @Test + fun `Success should only invoke successBlock with correct data`() { + val actualSuccessBlock = spyk({ data: Int -> + data shouldEqualTo number + Unit + }) + + success.handle(emptyStateBlock, emptyFailureBlock, actualSuccessBlock) + + verify { emptyStateBlock wasNot called } + verify { emptyFailureBlock wasNot called } + verify(exactly = 1) { actualSuccessBlock.invoke(any()) } + } + + @Test + fun `Failure should only invoke failureBlock with correct error`() { + val actualFailureBlock = spyk({ reason: Reason -> + reason shouldBeInstanceOf Reason.GenericError::class + (reason as Reason.GenericError).messageRes shouldEqualTo R.string.reason_generic + Unit + }) + + failure.handle(emptyStateBlock, actualFailureBlock, emptySuccessBlock) + + verify { emptySuccessBlock wasNot called } + verify { emptyStateBlock wasNot called } + verify(exactly = 1) { actualFailureBlock.invoke(any()) } + } + + @Test + fun `State should only invoke stateBlock with correct state`() { + val actualSuccessBlock = spyk({ state: Result.State -> + state shouldBeInstanceOf Result.State.Loading::class + Unit + }) + + state.handle(actualSuccessBlock, emptyFailureBlock, emptySuccessBlock) + + verify { emptySuccessBlock wasNot called } + verify { emptyFailureBlock wasNot called } + verify(exactly = 1) { actualSuccessBlock.invoke(any()) } + } +} diff --git a/repository/src/test/kotlin/com/melih/repository/sources/NetworkSourceTest.kt b/repository/src/test/kotlin/com/melih/repository/sources/NetworkSourceTest.kt new file mode 100644 index 0000000..55f9528 --- /dev/null +++ b/repository/src/test/kotlin/com/melih/repository/sources/NetworkSourceTest.kt @@ -0,0 +1,102 @@ +package com.melih.repository.sources + +import android.net.NetworkInfo +import com.melih.repository.R +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.entities.LaunchesEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import com.melih.repository.network.ApiImpl +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldEqualTo +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import javax.inject.Provider + +class NetworkSourceTest { + + private val apiImpl = mockk(relaxed = true) + private val networkInfoProvider = mockk>(relaxed = true) { + every { get() } returns mockk(relaxed = true) + } + + private val source = spyk(NetworkSource(apiImpl, networkInfoProvider)) + + @Nested + inner class GetNextLaunches { + + @Test + @ExperimentalCoroutinesApi + fun `should return network error when internet is not connected`() { + every { networkInfoProvider.get().isConnected } returns false + + runBlockingTest { + val result = source.getNextLaunches(1) + + result shouldBeInstanceOf Result.Failure::class + result.handleFailure { + it shouldBeInstanceOf Reason.NetworkError::class + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should return response error when it is not successful`() { + every { networkInfoProvider.get().isConnected } returns true + coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns false + + runBlockingTest { + val result = source.getNextLaunches(1) + + result shouldBeInstanceOf Result.Failure::class + result.handleFailure { + it shouldBeInstanceOf Reason.ResponseError::class + (it as Reason.ResponseError).messageRes shouldEqualTo R.string.reason_response + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should return empty result error when body is null`() { + every { networkInfoProvider.get().isConnected } returns true + coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true + coEvery { apiImpl.getNextLaunches(any()).body() } returns null + + runBlockingTest { + val result = source.getNextLaunches(1) + + result shouldBeInstanceOf Result.Failure::class + result.handleFailure { + it shouldBeInstanceOf Reason.EmptyResultError::class + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should return success with data if execution is successful`() { + every { networkInfoProvider.get().isConnected } returns true + coEvery { apiImpl.getNextLaunches(any()).isSuccessful } returns true + coEvery { apiImpl.getNextLaunches(any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013))) + + runBlockingTest { + val result = source.getNextLaunches(1) + + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it shouldBeInstanceOf List::class + it.size shouldEqualTo 1 + it[0].id shouldEqualTo 1013 + } + } + } + } +} diff --git a/repository/src/test/kotlin/com/melih/repository/sources/PersistanceSourceTest.kt b/repository/src/test/kotlin/com/melih/repository/sources/PersistanceSourceTest.kt new file mode 100644 index 0000000..a423423 --- /dev/null +++ b/repository/src/test/kotlin/com/melih/repository/sources/PersistanceSourceTest.kt @@ -0,0 +1,56 @@ +package com.melih.repository.sources + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import com.melih.repository.persistence.LaunchesDatabase +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldEqualTo +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class PersistanceSourceTest { + + private val dbImplementation = mockk(relaxed = true) + private val source = spyk(PersistenceSource(dbImplementation)) + + @Nested + inner class GetNextLaunches { + + @Test + @ExperimentalCoroutinesApi + fun `should return persistance empty error when db is empty`() { + runBlockingTest { + coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns emptyList() + + val result = source.getNextLaunches(10) + result shouldBeInstanceOf Result.Failure::class + result.handleFailure { + it shouldBeInstanceOf Reason.PersistenceEmpty::class + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should return success with data if db is not empty`() { + runBlockingTest { + coEvery { dbImplementation.launchesDao.getLaunches(any()) } returns listOf(LaunchEntity(id = 1013)) + + val result = source.getNextLaunches(10) + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it.isEmpty() shouldBe false + it.size shouldEqualTo 1 + it[0].id shouldEqualTo 1013 + } + } + } + } +} diff --git a/repository/src/test/kotlin/com/melih/repository/sources/SourceManagerTest.kt b/repository/src/test/kotlin/com/melih/repository/sources/SourceManagerTest.kt new file mode 100644 index 0000000..8415a8f --- /dev/null +++ b/repository/src/test/kotlin/com/melih/repository/sources/SourceManagerTest.kt @@ -0,0 +1,141 @@ +package com.melih.repository.sources + +import com.melih.repository.entities.LaunchEntity +import com.melih.repository.interactors.base.Reason +import com.melih.repository.interactors.base.Result +import io.mockk.called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest +import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldEqualTo +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class SourceManagerTest { + + private val networkSource = mockk(relaxed = true) + private val persistenceSource = mockk(relaxed = true) + + private val sourceManager = spyk(SourceManager(networkSource, persistenceSource)) + + @Nested + inner class GetNextLaunches { + + @Test + @ExperimentalCoroutinesApi + fun `should try to fetch, save and return result from persistance`() { + runBlockingTest { + val amount = 10 + coEvery { networkSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1012))) + coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013))) + + val result = sourceManager.getNextLaunches(amount) + + coVerifyOrder { + networkSource.getNextLaunches(any()) + persistenceSource.saveLaunches(any()) + persistenceSource.getNextLaunches(any()) + } + + coVerify(exactly = 1) { networkSource.getNextLaunches(any()) } + coVerify(exactly = 1) { persistenceSource.saveLaunches(any()) } + coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) } + + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it.size shouldEqualTo 1 + it[0].id shouldEqualTo 1013 + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should not save response if fetching was failure and return result from persistance`() { + runBlockingTest { + val amount = 10 + coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError()) + coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Success(listOf(LaunchEntity(id = 1013))) + + val result = sourceManager.getNextLaunches(amount) + + coVerify { persistenceSource.saveLaunches(any()) wasNot called } + coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) } + + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it.size shouldEqualTo 1 + it[0].id shouldEqualTo 1013 + } + } + } + + @Test + @ExperimentalCoroutinesApi + fun `should return failure if network and persistance fails`() { + runBlockingTest { + val amount = 10 + coEvery { networkSource.getNextLaunches(any()) } returns Result.Failure(Reason.NetworkError()) + coEvery { persistenceSource.getNextLaunches(any()) } returns Result.Failure(Reason.PersistenceEmpty()) + + val result = sourceManager.getNextLaunches(amount) + + coVerify { persistenceSource.saveLaunches(any()) wasNot called } + coVerify(exactly = 1) { persistenceSource.getNextLaunches(any()) } + + result shouldBeInstanceOf Result.Failure::class + result.handleFailure { + it shouldBeInstanceOf Reason.NoNetworkPersistenceEmpty::class + } + } + } + } + + @Nested + inner class GetLaunchDetails { + + @Test + fun `should return result from persistance immediately if it's found`() { + runBlocking { + coEvery { persistenceSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013)) + + val result = sourceManager.getLaunchById(1) + + coVerify { networkSource.getLaunchById(any()) wasNot called } + + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it.id shouldEqualTo 1013 + } + } + } + + @Test + fun `should fetch result from network if it's not found in persistance`() { + runBlocking { + coEvery { + persistenceSource.getLaunchById(any()) + } returns Result.Failure(Reason.PersistenceEmpty()) andThen Result.Success(LaunchEntity(id = 1013)) + + coEvery { networkSource.getLaunchById(any()) } returns Result.Success(LaunchEntity(id = 1013)) + + val result = sourceManager.getLaunchById(1) + + coVerify(exactly = 1) { networkSource.getLaunchById(any()) } + coVerify(exactly = 1) { persistenceSource.saveLaunch(any()) } + coVerify(exactly = 2) { persistenceSource.getLaunchById(any()) } + + result shouldBeInstanceOf Result.Success::class + result.handleSuccess { + it.id shouldEqualTo 1013 + } + } + } + } +} diff --git a/scripts/default_android_config.gradle b/scripts/default_android_config.gradle new file mode 100644 index 0000000..b5ab234 --- /dev/null +++ b/scripts/default_android_config.gradle @@ -0,0 +1,13 @@ +apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle" + +android { + compileSdkVersion versions.compileSdkVersion + + defaultConfig { + minSdkVersion versions.minSdkVersion + targetSdkVersion versions.targetSdkVersion + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } +} diff --git a/scripts/default_dependencies.gradle b/scripts/default_dependencies.gradle new file mode 100644 index 0000000..e5c9685 --- /dev/null +++ b/scripts/default_dependencies.gradle @@ -0,0 +1,16 @@ +apply from: "$rootProject.projectDir/scripts/detekt.gradle" +apply from: "$rootProject.projectDir/scripts/dokka.gradle" + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation libraries.kotlin + implementation libraries.dagger + implementation libraries.timber + + kapt annotationProcessors.daggerCompiler + + testImplementation testLibraries.jUnitApi + testImplementation testLibraries.mockk + testImplementation testLibraries.kluent +} diff --git a/scripts/dependencies.gradle b/scripts/dependencies.gradle new file mode 100644 index 0000000..b48f236 --- /dev/null +++ b/scripts/dependencies.gradle @@ -0,0 +1,146 @@ +ext { + + versions = [ + minSdkVersion : 16, + minSdkVersionDev : 21, + compileSdkVersion : 28, + targetSdkVersion : 28, + buildToolsVersion : "28.0.3", + supportLibraryVersion : "28.0.0", + appCompatVersion : "1.1.0-alpha04", + lifecycleVersion : "2.2.0-alpha01", + fragmentVersion : "1.1.0-beta01", + workManagerVersion : "2.1.0-alpha03", + constraintLayoutVesion: "2.0.0-beta1", + cardViewVersion : "1.0.0", + recyclerViewVersion : "1.1.0-alpha06", + pagingVersion : "2.1.0", + viewPagerVersion : "1.0.0-alpha05", + collectionVersion : "1.1.0", + roomVersion : "2.1.0", + daggerVersion : "2.22.1", + okHttpVersion : "3.12.0", + retrofitVersion : "2.6.0", + picassoVersion : "2.71828", + moshiVersion : "1.8.0", + coroutinesVersion : "1.3.0-M1", + leakCanaryVersion : "2.0-alpha-2", + timberVersion : "4.7.1", + jUnitVersion : "5.4.2", + espressoVersion : "3.2.0", + mockkVersion : "1.9.3", + kluentVersion : "1.49", + ] + + libraries = [ + /** + * Android libraries + */ + appCompat : "androidx.appcompat:appcompat:${versions.appCompatVersion}", + recyclerView : "androidx.recyclerview:recyclerview:${versions.recyclerViewVersion}", + cardView : "androidx.cardview:cardview:${versions.cardViewVersion}", + constraintLayout: "androidx.constraintlayout:constraintlayout:${versions.constraintLayoutVesion}", + multixDex : "androidx.multidex:multidex:2.0.1", + fragment : "androidx.fragment:fragment-ktx:${versions.fragmentVersion}", + + /** + * Jetpack + */ + navigation : [ + "androidx.navigation:navigation-fragment-ktx:$nav_version", + "androidx.navigation:navigation-ui-ktx:$nav_version" + ], + + room : [ + "androidx.room:room-runtime:${versions.roomVersion}", + "androidx.room:room-ktx:${versions.roomVersion}" + ], + + lifecycle : "androidx.lifecycle:lifecycle-extensions:${versions.lifecycleVersion}", + liveData : "androidx.lifecycle:lifecycle-livedata-ktx:${versions.lifecycleVersion}", + workManager : "androidx.work:work-runtime-ktx:${versions.workManagerVersion}", + paging : "androidx.paging:paging-runtime-ktx:${versions.pagingVersion}", + viewPager : "androidx.viewpager2:viewpager2:${versions.viewPagerVersion}", + collection : "androidx.collection:collection:${versions.collectionVersion}", + + /** + * Kotlin + */ + kotlin : "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version", + coroutines : "org.jetbrains.kotlinx:kotlinx-coroutines-android:${versions.coroutinesVersion}", + + /** + * Dagger + */ + dagger : [ + "com.google.dagger:dagger:${versions.daggerVersion}", + "com.google.dagger:dagger-android:${versions.daggerVersion}", + "com.google.dagger:dagger-android-support:${versions.daggerVersion}" + ], + + /** + * OkHttp + */ + okHttp : [ + "com.squareup.okhttp3:okhttp:${versions.okHttpVersion}", + "com.squareup.okhttp3:logging-interceptor:${versions.okHttpVersion}" + ], + + okHttpLogger : "com.squareup.okhttp3:logging-interceptor:${versions.okHttpVersion}", + + /** + * Retrofit + */ + retrofit : [ + "com.squareup.retrofit2:retrofit:${versions.retrofitVersion}", + "com.squareup.retrofit2:converter-moshi:${versions.retrofitVersion}" + ], + + /** + * Moshi + */ + moshi : [ + "com.squareup.moshi:moshi:${versions.moshiVersion}", + "com.squareup.moshi:moshi-kotlin:${versions.moshiVersion}" + ], + + moshiKotlin : "com.squareup.moshi:moshi-kotlin:${versions.moshiVersion}", + + /** + * Picasso for image loading + */ + picasso : "com.squareup.picasso:picasso:${versions.picassoVersion}", + + /** + * LeakCanary + */ + leakCanary : "com.squareup.leakcanary:leakcanary-android:${versions.leakCanaryVersion}", + + /** + * Timber + */ + timber : "com.jakewharton.timber:timber:${versions.timberVersion}" + ] + + annotationProcessors = [ + roomCompiler : "androidx.room:room-compiler:${versions.roomVersion}", + daggerCompiler: [ + "com.google.dagger:dagger-compiler:${versions.daggerVersion}", + "com.google.dagger:dagger-android-processor:${versions.daggerVersion}" + ], + ] + + testLibraries = [ + jUnitApi : "org.junit.jupiter:junit-jupiter-api:${versions.jUnitVersion}", + jUnitEngine : "org.junit.jupiter:junit-jupiter-engine:${versions.jUnitVersion}", + jUnitVintage : "org.junit.vintage:junit-vintage-engine:${versions.jUnitVersion}", + jUnitAndroid : "androidx.test.ext:junit:1.1.0", + fragmentTest : "androidx.fragment:fragment-testing:${versions.fragmentVersion}", + multidexInstrumentation: "androidx.multidex:multidex-instrumentation:2.0.0", + coroutinesCore : "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.coroutinesVersion}", + coroutinesTest : "org.jetbrains.kotlinx:kotlinx-coroutines-test:${versions.coroutinesVersion}", + espresso : "androidx.test.espresso:espresso-core:${versions.espressoVersion}", + mockk : "io.mockk:mockk:${versions.mockkVersion}", + kluent : "org.amshove.kluent:kluent-android:${versions.kluentVersion}" + ] +} diff --git a/scripts/detekt.gradle b/scripts/detekt.gradle new file mode 100644 index 0000000..17bbe39 --- /dev/null +++ b/scripts/detekt.gradle @@ -0,0 +1,14 @@ +apply plugin: 'io.gitlab.arturbosch.detekt' + +detekt { + toolVersion = "1.0.0-RC14" + config = files("$rootProject.projectDir/default-detekt-config.yml") + filters = ".*/resources/.*,.*/build/.*" + + reports { + html { + enabled = true + destination = file("$rootProject.projectDir/reports/detekt/$projectDir.name-report.html") + } + } +} diff --git a/scripts/dokka.gradle b/scripts/dokka.gradle new file mode 100644 index 0000000..59bed4c --- /dev/null +++ b/scripts/dokka.gradle @@ -0,0 +1,10 @@ +apply plugin: 'org.jetbrains.dokka-android' + +dokka { + outputFormat = "html" + outputDirectory = "$rootProject.projectDir/reports/javadoc" + jdkVersion = 8 + + reportUndocumented = true + skipEmptyPackages = true +} diff --git a/scripts/feature_module.gradle b/scripts/feature_module.gradle new file mode 100644 index 0000000..146c113 --- /dev/null +++ b/scripts/feature_module.gradle @@ -0,0 +1,16 @@ +apply from: "$rootProject.projectDir/scripts/module.gradle" + +android { + dataBinding { + enabled = true + } +} + +dependencies { + implementation project(':core') + + implementation libraries.fragment + implementation libraries.lifecycle + implementation libraries.navigation + implementation libraries.constraintLayout +} diff --git a/scripts/flavors.gradle b/scripts/flavors.gradle new file mode 100644 index 0000000..49de32e --- /dev/null +++ b/scripts/flavors.gradle @@ -0,0 +1,17 @@ +android { + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + + dev { + initWith debug + } + } +} diff --git a/scripts/module.gradle b/scripts/module.gradle new file mode 100644 index 0000000..cd8295f --- /dev/null +++ b/scripts/module.gradle @@ -0,0 +1,3 @@ +apply from: "$rootProject.projectDir/scripts/default_android_config.gradle" +apply from: "$rootProject.projectDir/scripts/sources.gradle" +apply from: "$rootProject.projectDir/scripts/flavors.gradle" \ No newline at end of file diff --git a/scripts/sources.gradle b/scripts/sources.gradle new file mode 100644 index 0000000..e44f000 --- /dev/null +++ b/scripts/sources.gradle @@ -0,0 +1,12 @@ +android { + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + androidTest.java.srcDirs += "src/androidTest/kotlin" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..6bb6ae3 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +include ':app', ':repository', ':core', ':features:list', ':features:detail' +rootProject.name = 'Rocket Science'