mirror of
https://github.com/melihaksoy/Android-Kotlin-Modulerized-CleanArchitecture.git
synced 2026-03-26 03:11:33 +01:00
Initial commit
This commit is contained in:
2
features/detail/src/main/AndroidManifest.xml
Normal file
2
features/detail/src/main/AndroidManifest.xml
Normal file
@@ -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
|
||||
}
|
||||
22
features/detail/src/main/res/layout/activity_detail.xml
Normal file
22
features/detail/src/main/res/layout/activity_detail.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<data class="DetailActivityBinding" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?android:actionBarSize"
|
||||
android:background="@color/colorPrimary" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</LinearLayout>
|
||||
</layout>
|
||||
75
features/detail/src/main/res/layout/fragment_detail.xml
Normal file
75
features/detail/src/main/res/layout/fragment_detail.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<data class="DetailBinding">
|
||||
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="com.melih.detail.ui.DetailViewModel" />
|
||||
</data>
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgRocket"
|
||||
imageUrl="@{viewModel.imageUrl}"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginStart="@dimen/padding_standard"
|
||||
android:layout_marginLeft="@dimen/padding_standard"
|
||||
android:layout_marginTop="@dimen/padding_standard"
|
||||
android:layout_marginEnd="@dimen/padding_standard"
|
||||
android:layout_marginRight="@dimen/padding_standard"
|
||||
android:contentDescription="@string/cd_rocket_image"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:srcCompat="@tools:sample/avatars[14]" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
style="@style/TitleTextStyle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/padding_standard"
|
||||
android:layout_marginLeft="@dimen/padding_standard"
|
||||
android:layout_marginTop="@dimen/padding_standard"
|
||||
android:layout_marginEnd="@dimen/padding_standard"
|
||||
android:layout_marginRight="@dimen/padding_standard"
|
||||
android:text="@{viewModel.rocketName}"
|
||||
android:textAppearance="@style/TitleTextAppearance"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgRocket"
|
||||
tools:text="@sample/launches.json/launches/name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="@dimen/padding_standard"
|
||||
android:layout_marginLeft="@dimen/padding_standard"
|
||||
android:layout_marginTop="@dimen/padding_standard"
|
||||
android:layout_marginEnd="@dimen/padding_standard"
|
||||
android:layout_marginRight="@dimen/padding_standard"
|
||||
android:layout_marginBottom="@dimen/padding_standard"
|
||||
android:text="@{viewModel.description}"
|
||||
android:textAppearance="@style/DescriptionTextAppearance"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
|
||||
tools:text="@sample/launches.json/launches/missions/description" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</layout>
|
||||
18
features/detail/src/main/res/navigation/nav_detail.xml
Normal file
18
features/detail/src/main/res/navigation/nav_detail.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/nav_detail"
|
||||
app:startDestination="@id/detailFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/detailFragment"
|
||||
android:name="com.melih.detail.ui.DetailFragment"
|
||||
android:label="DetailFragment"
|
||||
tools:layout="@layout/fragment_detail">
|
||||
<argument
|
||||
android:name="launchId"
|
||||
android:defaultValue="-1L"
|
||||
app:argType="long" />
|
||||
</fragment>
|
||||
</navigation>
|
||||
3
features/detail/src/main/res/values/strings.xml
Normal file
3
features/detail/src/main/res/values/strings.xml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user