Initial commit

This commit is contained in:
Melih Aksoy
2019-07-01 15:56:55 +02:00
commit 6029facf73
143 changed files with 6891 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+22
View File
@@ -0,0 +1,22 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs"
apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
android {
packagingOptions {
exclude 'META-INF/LICENSE.md'
exclude 'META-INF/LICENSE-notice.md'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.detail"/>
@@ -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
}
@@ -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
}
@@ -0,0 +1,39 @@
package com.melih.detail.ui
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import com.melih.core.actions.EXTRA_LAUNCH_ID
import com.melih.core.base.lifecycle.BaseActivity
import com.melih.detail.R
import com.melih.detail.databinding.DetailActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
const val INVALID_LAUNCH_ID = -1L
class DetailActivity : BaseActivity<DetailActivityBinding>() {
// region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true);
supportActionBar?.setDisplayShowHomeEnabled(true);
}
override fun getLayoutId(): Int = R.layout.activity_detail
override fun createNavHostFragment() =
NavHostFragment.create(
R.navigation.nav_detail,
DetailFragmentArgs.Builder()
.setLaunchId(intent?.extras?.getLong(EXTRA_LAUNCH_ID) ?: INVALID_LAUNCH_ID)
.build()
.toBundle()
)
override fun addNavHostTo(): Int = R.id.container
// endregion
}
@@ -0,0 +1,62 @@
package com.melih.detail.ui
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.View
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe
import com.melih.detail.R
import com.melih.detail.databinding.DetailBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
class DetailFragment : BaseDaggerFragment<DetailBinding>() {
// region Properties
private val args: DetailFragmentArgs by navArgs()
@ExperimentalCoroutinesApi
private val viewModel: DetailViewModel
get() = viewModelFactory.createFor(this)
// endregion
// region Functions
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.tvDescription.movementMethod = ScrollingMovementMethod()
binding.viewModel = viewModel
viewModel.createParamsFor(args.launchId)
viewModel.loadData()
// Observing state to show loading
observe(viewModel.stateData) {
// Loading can go here, skipping for now
}
// Observing error to show toast with retry action
observe(viewModel.errorData) {
Snackbar.make(
binding.root,
resources.getString(it.messageRes),
Snackbar.LENGTH_INDEFINITE
).setAction(com.melih.core.R.string.retry) {
viewModel.retry()
}.show()
}
observe(viewModel.successData) {
Timber.i("")
}
}
override fun getLayoutId(): Int = R.layout.fragment_detail
// endregion
}
@@ -0,0 +1,56 @@
package com.melih.detail.ui
import androidx.lifecycle.Transformations
import androidx.lifecycle.viewModelScope
import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunchDetails
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@ExperimentalCoroutinesApi
class DetailViewModel @Inject constructor(
private val getLaunchDetails: GetLaunchDetails
) : BaseViewModel<LaunchEntity>() {
// region Properties
private var params = GetLaunchDetails.Params(INVALID_LAUNCH_ID)
val rocketName = Transformations.map(successData) {
it.rocket.name
}
val description = Transformations.map(successData) {
if (it.missions.isEmpty()) {
""
} else {
it.missions[0].description
}
}
val imageUrl = Transformations.map(successData) {
it.rocket.imageURL
}
// endregion
// region Functions
fun createParamsFor(id: Long) {
params = GetLaunchDetails.Params(id)
}
/**
* Triggering interactor in view model scope
*/
override fun loadData() {
viewModelScope.launch {
getLaunchDetails(params).collect {
it.handle(::handleState, ::handleFailure, ::handleSuccess)
}
}
}
// endregion
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data class="DetailActivityBinding" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
android:background="@color/colorPrimary" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="DetailBinding">
<variable
name="viewModel"
type="com.melih.detail.ui.DetailViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{viewModel.imageUrl}"
android:layout_width="0dp"
android:layout_height="220dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<TextView
android:id="@+id/tvTitle"
style="@style/TitleTextStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{viewModel.rocketName}"
android:textAppearance="@style/TitleTextAppearance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<TextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:layout_marginBottom="@dimen/padding_standard"
android:text="@{viewModel.description}"
android:textAppearance="@style/DescriptionTextAppearance"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_detail"
app:startDestination="@id/detailFragment">
<fragment
android:id="@+id/detailFragment"
android:name="com.melih.detail.ui.DetailFragment"
android:label="DetailFragment"
tools:layout="@layout/fragment_detail">
<argument
android:name="launchId"
android:defaultValue="-1L"
app:argType="long" />
</fragment>
</navigation>
@@ -0,0 +1,3 @@
<resources>
<string name="cd_rocket_image">Image of the rocket</string>
</resources>
@@ -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()
}
}
@@ -0,0 +1,42 @@
package com.melih.detail
import com.melih.detail.ui.DetailViewModel
import com.melih.list.BaseTestWithMainThread
import com.melih.repository.interactors.GetLaunchDetails
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class DetailViewModelTest : BaseTestWithMainThread() {
private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true)
@ExperimentalCoroutinesApi
private val viewModel = spyk(DetailViewModel(getLaunchDetails))
@Test
@ExperimentalCoroutinesApi
fun `loadData should invoke getLauchDetails with provided params`() {
dispatcher.runBlockingTest {
val paramsSlot = slot<GetLaunchDetails.Params>()
viewModel.createParamsFor(1013)
viewModel.loadData()
// init should have called it already due to creation above
verify(exactly = 1) { getLaunchDetails(capture(paramsSlot)) }
paramsSlot.captured.id shouldEqualTo 1013
}
}
}
+1
View File
@@ -0,0 +1 @@
/build
+14
View File
@@ -0,0 +1,14 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testImplementation testLibraries.coroutinesTest
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
<manifest package="com.melih.list" />
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -0,0 +1,28 @@
package com.melih.list.ui
import android.os.Bundle
import androidx.navigation.fragment.NavHostFragment
import com.melih.core.base.lifecycle.BaseActivity
import com.melih.list.R
import com.melih.list.databinding.LaunchesActivityBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
class LaunchesActivity : BaseActivity<LaunchesActivityBinding>() {
// region Functions
@ExperimentalCoroutinesApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(binding.toolbar)
}
override fun getLayoutId(): Int = R.layout.activity_launches
override fun createNavHostFragment() =
NavHostFragment.create(R.navigation.nav_launches)
override fun addNavHostTo(): Int = R.id.container
// endregion
}
@@ -0,0 +1,41 @@
package com.melih.list.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import com.melih.core.base.recycler.BaseListAdapter
import com.melih.core.base.recycler.BaseViewHolder
import com.melih.list.databinding.LaunchRowBinding
import com.melih.repository.entities.LaunchEntity
class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BaseListAdapter<LaunchEntity>(
object : DiffUtil.ItemCallback<LaunchEntity>() {
override fun areItemsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: LaunchEntity, newItem: LaunchEntity): Boolean =
oldItem.name == newItem.name
},
itemClickListener
) {
override fun createViewHolder(
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): BaseViewHolder<LaunchEntity> =
LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false))
}
class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) {
override fun bind(item: LaunchEntity) {
binding.entity = item
val missions = item.missions
binding.tvDescription.text = if (missions.isNotEmpty()) missions[0].description else ""
binding.executePendingBindings()
}
}
@@ -0,0 +1,114 @@
package com.melih.list.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import com.melih.core.actions.Actions
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.createFor
import com.melih.core.extensions.observe
import com.melih.list.R
import com.melih.list.databinding.ListBinding
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.OnRefreshListener {
// region Properties
@ExperimentalCoroutinesApi
private val viewModel: LaunchesViewModel
get() = viewModelFactory.createFor(this)
private val launchesAdapter = LaunchesAdapter(::onItemSelected)
private val itemList = mutableListOf<LaunchEntity>()
// endregion
// region Functions
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true)
binding.rocketList.adapter = launchesAdapter
binding.swipeRefreshLayout.setOnRefreshListener(this)
// Observing state to show loading
observe(viewModel.stateData) {
binding.swipeRefreshLayout.isRefreshing = it is Result.State.Loading
}
// Observing error to show toast with retry action
observe(viewModel.errorData) {
Snackbar.make(
binding.root,
resources.getString(it.messageRes),
Snackbar.LENGTH_INDEFINITE
).setAction(com.melih.core.R.string.retry) {
viewModel.retry()
}.show()
}
observe(viewModel.successData) {
itemList.addAll(it)
launchesAdapter.submitList(itemList)
binding.rocketList.scheduleLayoutAnimation()
}
}
private fun onItemSelected(item: LaunchEntity) {
Timber.i("${item.id}")
startActivity(Actions.openDetailFor(item.id))
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_rocket_list, menu)
(menu.findItem(R.id.search).actionView as SearchView).apply {
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
clearFocus()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
launchesAdapter.submitList(
if (!newText.isNullOrBlank()) {
itemList.filter {
it.rocket.name.contains(
newText,
true
) || (it.missions.size > 0 && it.missions[0].description.contains(
newText,
true
))
}
} else {
itemList
}
)
return true
}
})
}
super.onCreateOptionsMenu(menu, inflater)
}
@ExperimentalCoroutinesApi
override fun onRefresh() {
viewModel.refresh()
}
override fun getLayoutId(): Int = R.layout.fragment_launches
// endregion
}
@@ -0,0 +1,38 @@
package com.melih.list.ui
import androidx.lifecycle.viewModelScope
import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunches
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
@ExperimentalCoroutinesApi
class LaunchesViewModel @Inject constructor(
private val getLaunches: GetLaunches,
private val getLaunchesParams: GetLaunches.Params
) : BaseViewModel<List<LaunchEntity>>() {
// region Initialization
init {
loadData()
}
// endregion
// region Functions
/**
* Triggering interactor in view model scope
*/
override fun loadData() {
viewModelScope.launch {
getLaunches(getLaunchesParams).collect {
it.handle(::handleState, ::handleFailure, ::handleSuccess)
}
}
}
// endregion
}
@@ -0,0 +1,22 @@
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="@integer/anim_duration">
<translate
android:fromYDelta="-20%"
android:interpolator="@android:anim/decelerate_interpolator"
android:toYDelta="0" />
<alpha
android:fromAlpha="0"
android:interpolator="@android:anim/decelerate_interpolator"
android:toAlpha="1" />
<scale
android:fromXScale="105%"
android:fromYScale="105%"
android:interpolator="@android:anim/decelerate_interpolator"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="100%"
android:toYScale="100%" />
</set>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/item_enter"
android:animationOrder="normal"
android:delay="15%" />
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data class="LaunchesActivityBinding" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:actionBarSize"
android:background="@color/colorPrimary" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="ListBinding">
<variable
name="viewModel"
type="com.melih.list.ui.LaunchesViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rocketList"
android:layout_width="0dp"
android:layout_height="0dp"
android:layoutAnimation="@anim/layout_item_enter"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="@layout/row_launch" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="LaunchRowBinding">
<variable
name="entity"
type="com.melih.repository.entities.LaunchEntity" />
</data>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
app:cardCornerRadius="@dimen/corner_radius_standard"
app:cardElevation="10dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="160dp">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{entity.rocket.imageURL}"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<TextView
android:id="@+id/tvDescription"
style="@style/ShortDescriptionTextStyle"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:textAppearance="@style/DescriptionTextAppearance"
app:layout_constraintBottom_toBottomOf="@+id/imgRocket"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
<TextView
android:id="@+id/tvTitle"
style="@style/TitleTextStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{entity.rocket.name}"
android:textAppearance="@style/TitleTextAppearance"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toTopOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/search"
android:icon="@android:drawable/ic_menu_search"
android:title="@string/search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView" />
</menu>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_launches"
app:startDestination="@id/launchesFragment">
<fragment
android:id="@+id/launchesFragment"
android:name="com.melih.list.ui.LaunchesFragment"
android:label="LaunchesFragment"
tools:layout="@layout/fragment_launches" />
</navigation>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="anim_duration">350</integer>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="cd_rocket_image">Image of the rocket</string>
<string name="search">Search</string>
</resources>
@@ -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()
}
}
@@ -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) }
}
}
}