diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..da044eaa
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+indent_size=2
+end_of_line=lf
+charset=utf-8
+trim_trailing_whitespace=true
+insert_final_newline=true
+
+[*.{kt,kts}]
+kotlin_imports_layout=ascii
diff --git a/.gitignore b/.gitignore
index 603b1407..a26b43b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
/.idea/assetWizardSettings.xml
.DS_Store
/build
+buildSrc/build
/captures
.externalNativeBuild
.cxx
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 3c7772a0..d8d3993c 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -3,7 +3,6 @@
+
+
@@ -134,6 +135,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index a55e7a17..79ee123c 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 00000000..910613c2
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 9d38be25..b61d88ce 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -12,6 +12,12 @@
+
+
+
+
+
+
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 5b709191..00000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,77 +0,0 @@
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-
-android {
- compileSdkVersion 30
- buildToolsVersion '30.0.1'
-
- defaultConfig {
- applicationId "com.hoc.flowmvi"
- minSdkVersion 21
- targetSdkVersion 30
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
-
- compileOptions {
- sourceCompatibility = 1.8
- targetCompatibility = 1.8
- }
-
- kotlinOptions {
- jvmTarget = "1.8"
- }
-
- buildFeatures {
- viewBinding = true
- }
-}
-
-dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
-
- implementation 'androidx.appcompat:appcompat:1.3.0-alpha01'
- implementation 'androidx.core:core-ktx:1.5.0-alpha01'
- implementation 'androidx.constraintlayout:constraintlayout:2.0.0-rc1'
- implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha05'
- implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01'
- implementation 'com.google.android.material:material:1.3.0-alpha02'
-
- // viewModelScope
- implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-alpha06'
-
- // lifecycleScope
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-alpha06'
-
- // retrofit2
- implementation 'com.squareup.retrofit2:retrofit:2.9.0'
- implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
- implementation 'com.squareup.okhttp3:logging-interceptor:4.8.0'
-
- // moshi
- implementation 'com.squareup.moshi:moshi-kotlin:1.9.2'
-
- // coroutines
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8'
-
- // koin
- implementation 'org.koin:koin-androidx-viewmodel:2.1.6'
-
- // coil
- implementation 'io.coil-kt:coil:0.11.0'
-
- testImplementation 'junit:junit:4.13'
- androidTestImplementation 'androidx.test.ext:junit:1.1.1'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..a8ca5d97
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ androidApplication
+ kotlinAndroid
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ applicationId = appConfig.applicationId
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+ buildFeatures { viewBinding = true }
+}
+
+dependencies {
+ implementation(
+ fileTree(
+ mapOf(
+ "dir" to "libs",
+ "include" to listOf("*.jar")
+ )
+ )
+ )
+
+ implementation(domain)
+ implementation(data)
+ implementation(core)
+ implementation(featureMain)
+ implementation(featureAdd)
+
+ implementation(deps.jetbrains.coroutinesAndroid)
+ implementation(deps.koin.android)
+
+ testImplementation(deps.test.junit)
+ androidTestImplementation(deps.test.androidxJunit)
+ androidTestImplementation(deps.test.androidXSspresso)
+}
diff --git a/app/src/androidTest/java/com/hoc/flowmvi/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/hoc/flowmvi/ExampleInstrumentedTest.kt
index 40c291bd..de51ea72 100644
--- a/app/src/androidTest/java/com/hoc/flowmvi/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/hoc/flowmvi/ExampleInstrumentedTest.kt
@@ -1,13 +1,11 @@
package com.hoc.flowmvi
-import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
-
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.Assert.*
-
/**
* Instrumented test, which will execute on an Android device.
*
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c1691e43..2198a87a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,21 +13,6 @@
android:supportsRtl="true"
android:theme="@style/AppTheme">
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/hoc/flowmvi/App.kt b/app/src/main/java/com/hoc/flowmvi/App.kt
index 6b978d00..b2568d87 100644
--- a/app/src/main/java/com/hoc/flowmvi/App.kt
+++ b/app/src/main/java/com/hoc/flowmvi/App.kt
@@ -1,9 +1,11 @@
package com.hoc.flowmvi
import android.app.Application
-import com.hoc.flowmvi.koin.dataModule
-import com.hoc.flowmvi.koin.domainModule
-import com.hoc.flowmvi.koin.viewModelModule
+import com.hoc.flowmvi.core.coreModule
+import com.hoc.flowmvi.data.dataModule
+import com.hoc.flowmvi.domain.domainModule
+import com.hoc.flowmvi.ui.add.addModule
+import com.hoc.flowmvi.ui.main.mainModule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import org.koin.android.ext.koin.androidContext
@@ -21,14 +23,15 @@ class App : Application() {
startKoin {
androidContext(this@App)
- // https://github.com/InsertKoinIO/koin/issues/847
- androidLogger(level = Level.ERROR)
+ androidLogger(level = Level.DEBUG)
modules(
- dataModule,
- domainModule,
- viewModelModule
+ coreModule,
+ dataModule,
+ domainModule,
+ mainModule,
+ addModule,
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
new file mode 100644
index 00000000..135aff92
--- /dev/null
+++ b/app/src/main/java/com/hoc/flowmvi/core/CoreModule.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.core
+
+import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
+import com.hoc.flowmvi.core.navigator.Navigator
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import org.koin.dsl.module
+
+@FlowPreview
+@ExperimentalCoroutinesApi
+val coreModule = module {
+ single { CoroutineDispatchersImpl() }
+
+ single { NavigatorImpl(add = get()) }
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt b/app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt
new file mode 100644
index 00000000..f9c86268
--- /dev/null
+++ b/app/src/main/java/com/hoc/flowmvi/core/CoroutineDispatchersImpl.kt
@@ -0,0 +1,10 @@
+package com.hoc.flowmvi.core
+
+import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+internal class CoroutineDispatchersImpl(
+ override val main: CoroutineDispatcher = Dispatchers.Main,
+ override val io: CoroutineDispatcher = Dispatchers.IO
+) : CoroutineDispatchers
diff --git a/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt b/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt
new file mode 100644
index 00000000..3a919743
--- /dev/null
+++ b/app/src/main/java/com/hoc/flowmvi/core/NavigatorImpl.kt
@@ -0,0 +1,16 @@
+package com.hoc.flowmvi.core
+
+import android.content.Context
+import com.hoc.flowmvi.core.navigator.IntentProviders
+import com.hoc.flowmvi.core.navigator.Navigator
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+
+@ExperimentalCoroutinesApi
+@FlowPreview
+class NavigatorImpl(
+ private val add: IntentProviders.Add
+) : Navigator {
+ override fun Context.navigateToAdd() =
+ startActivity(add.makeIntent(this))
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt b/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
deleted file mode 100644
index b91a971c..00000000
--- a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.hoc.flowmvi.data.mapper
-
-import com.hoc.flowmvi.data.remote.UserBody
-import com.hoc.flowmvi.domain.Mapper
-import com.hoc.flowmvi.domain.entity.User
-
-class UserDomainToUserBodyMapper : Mapper {
- override fun invoke(domain: User): UserBody {
- return UserBody(
- email = domain.email,
- avatar = domain.avatar,
- firstName = domain.firstName,
- lastName = domain.lastName
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt b/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
deleted file mode 100644
index 8931f9ba..00000000
--- a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.hoc.flowmvi.data.mapper
-
-import com.hoc.flowmvi.data.remote.UserResponse
-import com.hoc.flowmvi.domain.Mapper
-import com.hoc.flowmvi.domain.entity.User
-
-class UserDomainToUserResponseMapper : Mapper {
- override fun invoke(domain: User): UserResponse {
- return UserResponse(
- id = domain.id,
- avatar = domain.avatar,
- email = domain.email,
- firstName = domain.firstName,
- lastName = domain.lastName
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt b/app/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt
deleted file mode 100644
index fc766e47..00000000
--- a/app/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.hoc.flowmvi.data.mapper
-
-import com.hoc.flowmvi.data.remote.UserResponse
-import com.hoc.flowmvi.domain.Mapper
-import com.hoc.flowmvi.domain.entity.User
-
-class UserResponseToUserDomainMapper : Mapper {
- override fun invoke(response: UserResponse): User {
- return User(
- id = response.id,
- avatar = response.avatar,
- email = response.email,
- firstName = response.firstName,
- lastName = response.lastName
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt b/app/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
deleted file mode 100644
index 6f390583..00000000
--- a/app/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.hoc.flowmvi.data.remote
-
-import com.squareup.moshi.Json
-
-data class UserBody(
- @Json(name = "email")
- val email: String,
- @Json(name = "first_name")
- val firstName: String,
- @Json(name = "last_name")
- val lastName: String,
- @Json(name = "avatar")
- val avatar: String
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt b/app/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt
deleted file mode 100644
index e069033b..00000000
--- a/app/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.hoc.flowmvi.data.remote
-
-import com.squareup.moshi.Json
-
-data class UserResponse(
- @Json(name = "_id")
- val id: String,
- @Json(name = "email")
- val email: String,
- @Json(name = "first_name")
- val firstName: String,
- @Json(name = "last_name")
- val lastName: String,
- @Json(name = "avatar")
- val avatar: String
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/Mapper.kt b/app/src/main/java/com/hoc/flowmvi/domain/Mapper.kt
deleted file mode 100644
index 2c0a68e2..00000000
--- a/app/src/main/java/com/hoc/flowmvi/domain/Mapper.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.hoc.flowmvi.domain
-
-typealias Mapper = (T) -> R
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchersImpl.kt b/app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchersImpl.kt
deleted file mode 100644
index a28e537d..00000000
--- a/app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchersImpl.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.hoc.flowmvi.domain.dispatchers
-
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.Dispatchers
-
-class CoroutineDispatchersImpl(
- override val main: CoroutineDispatcher = Dispatchers.Main,
- override val io: CoroutineDispatcher = Dispatchers.IO
-) : CoroutineDispatchers
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/entity/User.kt b/app/src/main/java/com/hoc/flowmvi/domain/entity/User.kt
deleted file mode 100644
index 4787e9c2..00000000
--- a/app/src/main/java/com/hoc/flowmvi/domain/entity/User.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.hoc.flowmvi.domain.entity
-
-data class User(
- val id: String,
- val email: String,
- val firstName: String,
- val lastName: String,
- val avatar: String
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/koin/DomainModule.kt b/app/src/main/java/com/hoc/flowmvi/koin/DomainModule.kt
deleted file mode 100644
index 228e1a99..00000000
--- a/app/src/main/java/com/hoc/flowmvi/koin/DomainModule.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.hoc.flowmvi.koin
-
-import com.hoc.flowmvi.data.UserRepositoryImpl
-import com.hoc.flowmvi.data.mapper.UserDomainToUserBodyMapper
-import com.hoc.flowmvi.data.mapper.UserDomainToUserResponseMapper
-import com.hoc.flowmvi.data.mapper.UserResponseToUserDomainMapper
-import com.hoc.flowmvi.domain.dispatchers.CoroutineDispatchers
-import com.hoc.flowmvi.domain.dispatchers.CoroutineDispatchersImpl
-import com.hoc.flowmvi.domain.repository.UserRepository
-import com.hoc.flowmvi.domain.usecase.AddUserUseCase
-import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
-import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase
-import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-import org.koin.dsl.module
-
-@ExperimentalCoroutinesApi
-@FlowPreview
-val domainModule = module {
- single { CoroutineDispatchersImpl() }
-
- single {
- UserRepositoryImpl(
- userApiService = get(),
- dispatchers = get(),
- responseToDomain = get(),
- domainToResponse = get(),
- domainToBody = get()
- )
- }
-
- factory { GetUsersUseCase(userRepository = get()) }
-
- factory { RefreshGetUsersUseCase(userRepository = get()) }
-
- factory { RemoveUserUseCase(userRepository = get()) }
-
- factory { AddUserUseCase(userRepository = get()) }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/koin/ViewModelModule.kt b/app/src/main/java/com/hoc/flowmvi/koin/ViewModelModule.kt
deleted file mode 100644
index 5301921b..00000000
--- a/app/src/main/java/com/hoc/flowmvi/koin/ViewModelModule.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.hoc.flowmvi.koin
-
-import com.hoc.flowmvi.ui.add.AddVM
-import com.hoc.flowmvi.ui.main.MainVM
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-import org.koin.androidx.viewmodel.dsl.viewModel
-import org.koin.dsl.module
-
-@ExperimentalCoroutinesApi
-@FlowPreview
-val viewModelModule = module {
- viewModel { MainVM(get(), get(), get()) }
-
- viewModel { AddVM(get()) }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/app/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
deleted file mode 100644
index af369151..00000000
--- a/app/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.hoc.flowmvi.ui.add
-
-import com.hoc.flowmvi.domain.entity.User
-import kotlinx.coroutines.flow.Flow
-
-interface AddContract {
- interface View {
- fun intents(): Flow
- }
-
- enum class ValidationError {
- INVALID_EMAIL_ADDRESS,
- TOO_SHORT_FIRST_NAME,
- TOO_SHORT_LAST_NAME
- }
-
- data class ViewState(
- val errors: Set,
- val isLoading: Boolean,
- val emailChanged: Boolean,
- val firstNameChanged: Boolean,
- val lastNameChanged: Boolean,
- ) {
- companion object {
- fun initial() = ViewState(
- errors = emptySet(),
- isLoading = false,
- emailChanged = false,
- firstNameChanged = false,
- lastNameChanged = false,
- )
- }
- }
-
- sealed class ViewIntent {
- data class EmailChanged(val email: String?) : ViewIntent()
- data class FirstNameChanged(val firstName: String?) : ViewIntent()
- data class LastNameChanged(val lastName: String?) : ViewIntent()
-
- object Submit : ViewIntent()
-
- object EmailChangedFirstTime : ViewIntent()
- object FirstNameChangedFirstTime : ViewIntent()
- object LastNameChangedFirstTime : ViewIntent()
- }
-
- sealed class PartialStateChange {
- abstract fun reduce(viewState: ViewState): ViewState
-
- data class ErrorsChanged(val errors: Set) : PartialStateChange() {
- override fun reduce(viewState: ViewState) = viewState.copy(errors = errors)
- }
-
- sealed class AddUser : PartialStateChange() {
- object Loading : AddUser()
- data class AddUserSuccess(val user: User) : AddUser()
- data class AddUserFailure(val user: User, val throwable: Throwable) : AddUser()
-
- override fun reduce(viewState: ViewState): ViewState {
- return when (this) {
- Loading -> viewState.copy(isLoading = true)
- is AddUserSuccess -> viewState.copy(isLoading = false)
- is AddUserFailure -> viewState.copy(isLoading = false)
- }
- }
- }
-
- sealed class FirstChange : PartialStateChange() {
- object EmailChangedFirstTime : FirstChange()
- object FirstNameChangedFirstTime : FirstChange()
- object LastNameChangedFirstTime : FirstChange()
-
- override fun reduce(viewState: ViewState): ViewState {
- return when (this) {
- EmailChangedFirstTime -> viewState.copy(emailChanged = true)
- FirstNameChangedFirstTime -> viewState.copy(firstNameChanged = true)
- LastNameChangedFirstTime -> viewState.copy(lastNameChanged = true)
- }
- }
- }
- }
-
- sealed class SingleEvent {
- data class AddUserSuccess(val user: User) : SingleEvent()
- data class AddUserFailure(val user: User, val throwable: Throwable) : SingleEvent()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/app/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
deleted file mode 100644
index 83246277..00000000
--- a/app/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.hoc.flowmvi.ui.main
-
-import com.hoc.flowmvi.domain.entity.User
-import kotlinx.coroutines.flow.Flow
-
-
-interface MainContract {
- interface View {
- fun intents(): Flow
- }
-
- data class UserItem(
- val id: String,
- val email: String,
- val avatar: String,
- val firstName: String,
- val lastName: String
- ) {
- val fullName get() = "$firstName $lastName"
-
- constructor(domain: User) : this(
- id = domain.id,
- email = domain.email,
- avatar = domain.avatar,
- firstName = domain.firstName,
- lastName = domain.lastName
- )
-
- fun toDomain() = User(
- id = id,
- lastName = lastName,
- firstName = firstName,
- avatar = avatar,
- email = email
- )
- }
-
- sealed class ViewIntent {
- object Initial : ViewIntent()
- object Refresh : ViewIntent()
- object Retry : ViewIntent()
- data class RemoveUser(val user: UserItem) : ViewIntent()
- }
-
- data class ViewState(
- val userItems: List,
- val isLoading: Boolean,
- val error: Throwable?,
- val isRefreshing: Boolean
- ) {
- companion object {
- fun initial() = ViewState(
- userItems = emptyList(),
- isLoading = true,
- error = null,
- isRefreshing = false
- )
- }
- }
-
- sealed class PartialChange {
- abstract fun reduce(vs: ViewState): ViewState
-
- sealed class GetUser : PartialChange() {
- override fun reduce(vs: ViewState): ViewState {
- return when (this) {
- Loading -> vs.copy(
- isLoading = true,
- error = null
- )
- is Data -> vs.copy(
- isLoading = false,
- error = null,
- userItems = users
- )
- is Error -> vs.copy(
- isLoading = false,
- error = error
- )
- }
- }
-
- object Loading : GetUser()
- data class Data(val users: List) : GetUser()
- data class Error(val error: Throwable) : GetUser()
- }
-
- sealed class Refresh : PartialChange() {
- override fun reduce(vs: ViewState): ViewState {
- return when (this) {
- is Success -> vs.copy(isRefreshing = false)
- is Failure -> vs.copy(isRefreshing = false)
- Loading -> vs.copy(isRefreshing = true)
- }
- }
-
- object Loading : Refresh()
- object Success : Refresh()
- data class Failure(val error: Throwable) : Refresh()
- }
-
- sealed class RemoveUser : PartialChange() {
- data class Success(val user: UserItem) : RemoveUser()
- data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
-
- override fun reduce(vs: ViewState) = vs
- }
- }
-
- sealed class SingleEvent {
- sealed class Refresh : SingleEvent() {
- object Success : Refresh()
- data class Failure(val error: Throwable) : Refresh()
- }
-
- data class GetUsersError(val error: Throwable) : SingleEvent()
-
- sealed class RemoveUser : SingleEvent() {
- data class Success(val user: UserItem) : RemoveUser()
- data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/app/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
deleted file mode 100644
index 4878b889..00000000
--- a/app/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.hoc.flowmvi.ui.main
-
-import android.util.Log
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
-import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase
-import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
-import com.hoc.flowmvi.flatMapFirst
-import com.hoc.flowmvi.ui.main.MainContract.*
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.channels.BroadcastChannel
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
-
-@Suppress("USELESS_CAST")
-@FlowPreview
-@ExperimentalCoroutinesApi
-class MainVM(
- private val getUsersUseCase: GetUsersUseCase,
- private val refreshGetUsers: RefreshGetUsersUseCase,
- private val removeUser: RemoveUserUseCase,
-) : ViewModel() {
- private val _eventChannel = BroadcastChannel(capacity = Channel.BUFFERED)
- private val _intentChannel = BroadcastChannel(capacity = Channel.BUFFERED)
-
- val viewState: StateFlow
-
- val singleEvent: Flow
-
- suspend fun processIntent(intent: ViewIntent) = _intentChannel.send(intent)
-
- init {
- val initialVS = ViewState.initial()
-
- viewState = MutableStateFlow(initialVS)
- singleEvent = _eventChannel.asFlow()
-
- val intentFlow = _intentChannel.asFlow()
- merge(
- intentFlow.filterIsInstance().take(1),
- intentFlow.filterNot { it is ViewIntent.Initial }
- )
- .toPartialChangeFlow()
- .sendSingleEvent()
- .scan(initialVS) { vs, change -> change.reduce(vs) }
- .onEach { viewState.value = it }
- .catch { }
- .launchIn(viewModelScope)
- }
-
- private fun Flow.sendSingleEvent(): Flow {
- return onEach {
- val event = when (it) {
- is PartialChange.GetUser.Error -> SingleEvent.GetUsersError(it.error)
- is PartialChange.Refresh.Success -> SingleEvent.Refresh.Success
- is PartialChange.Refresh.Failure -> SingleEvent.Refresh.Failure(it.error)
- is PartialChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(it.user)
- is PartialChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure(
- user = it.user,
- error = it.error,
- )
- PartialChange.GetUser.Loading -> return@onEach
- is PartialChange.GetUser.Data -> return@onEach
- PartialChange.Refresh.Loading -> return@onEach
- }
- _eventChannel.send(event)
- }
- }
-
- private fun Flow.toPartialChangeFlow(): Flow {
- val getUserChanges = getUsersUseCase()
- .onEach { Log.d("###", "[MAIN_VM] Emit users.size=${it.size}") }
- .map {
- val items = it.map(::UserItem)
- PartialChange.GetUser.Data(items) as PartialChange.GetUser
- }
- .onStart { emit(PartialChange.GetUser.Loading) }
- .catch { emit(PartialChange.GetUser.Error(it)) }
-
- val refreshChanges = refreshGetUsers::invoke
- .asFlow()
- .map { PartialChange.Refresh.Success as PartialChange.Refresh }
- .onStart { emit(PartialChange.Refresh.Loading) }
- .catch { emit(PartialChange.Refresh.Failure(it)) }
-
- return merge(
- filterIsInstance()
- .logIntent()
- .flatMapConcat { getUserChanges },
- filterIsInstance()
- .filter { viewState.value.let { !it.isLoading && it.error === null } }
- .logIntent()
- .flatMapFirst { refreshChanges },
- filterIsInstance()
- .filter { viewState.value.error != null }
- .logIntent()
- .flatMapFirst { getUserChanges },
- filterIsInstance()
- .logIntent()
- .map { it.user }
- .flatMapMerge { userItem ->
- flow {
- userItem
- .toDomain()
- .let { removeUser(it) }
- .let { emit(it) }
- }
- .map { PartialChange.RemoveUser.Success(userItem) as PartialChange.RemoveUser }
- .catch { emit(PartialChange.RemoveUser.Failure(userItem, it)) }
- }
- )
- }
-
- private fun Flow.logIntent() = onEach { Log.d("MainVM", "## Intent: $it") }
-}
diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
index 2b068d11..7706ab9e 100644
--- a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -27,4 +27,4 @@
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index eca70cfe..6b78462d 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,4 @@
-
\ 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
index eca70cfe..6b78462d 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt b/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt
index a31fa5f7..6e1e6f17 100644
--- a/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt
+++ b/app/src/test/java/com/hoc/flowmvi/ExampleUnitTest.kt
@@ -1,9 +1,8 @@
package com.hoc.flowmvi
+import org.junit.Assert.assertEquals
import org.junit.Test
-import org.junit.Assert.*
-
/**
* Example local unit test, which will execute on the development machine (host).
*
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 76b9d2b1..00000000
--- a/build.gradle
+++ /dev/null
@@ -1,30 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
- ext.kotlin_version = '1.4.0-rc'
- repositories {
- google()
- jcenter()
- maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:4.0.1'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-allprojects {
- repositories {
- google()
- jcenter()
- mavenCentral()
- maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..f05f118e
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,82 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+ val kotlinVersion by extra("1.4.0-rc")
+ repositories {
+ google()
+ jcenter()
+ maven(url = "https://dl.bintray.com/kotlin/kotlin-eap")
+ gradlePluginPortal()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:4.0.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ classpath("com.diffplug.spotless:spotless-plugin-gradle:5.3.0")
+ }
+}
+
+subprojects {
+ apply(plugin = "com.diffplug.spotless")
+
+ configure {
+ kotlin {
+ target("**/*.kt")
+
+ ktlint(ktlintVersion).userData(
+ // TODO this should all come from editorconfig https://github.com/diffplug/spotless/issues/142
+ mapOf(
+ "indent_size" to "2",
+ "kotlin_imports_layout" to "ascii"
+ )
+ )
+
+ trimTrailingWhitespace()
+ indentWithSpaces()
+ endWithNewline()
+ }
+
+ format("xml") {
+ target("**/res/**/*.xml")
+
+ trimTrailingWhitespace()
+ indentWithSpaces()
+ endWithNewline()
+ }
+
+ kotlinGradle {
+ target("**/*.gradle.kts", "*.gradle.kts")
+
+ ktlint(ktlintVersion).userData(
+ mapOf(
+ "indent_size" to "2",
+ "kotlin_imports_layout" to "ascii"
+ )
+ )
+
+ trimTrailingWhitespace()
+ indentWithSpaces()
+ endWithNewline()
+ }
+ }
+}
+
+allprojects {
+ tasks.withType {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ sourceCompatibility = JavaVersion.VERSION_1_8.toString()
+ targetCompatibility = JavaVersion.VERSION_1_8.toString()
+ }
+ }
+
+ repositories {
+ google()
+ jcenter()
+ mavenCentral()
+ maven(url = "https://dl.bintray.com/kotlin/kotlin-eap")
+ }
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.buildDir)
+}
diff --git a/buildSrc/.editorconfig b/buildSrc/.editorconfig
new file mode 100644
index 00000000..59e5b1b3
--- /dev/null
+++ b/buildSrc/.editorconfig
@@ -0,0 +1,2 @@
+[{*.kt, *.kts}]
+max_line_length = 200
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 00000000..26dc658c
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,8 @@
+repositories {
+ mavenCentral()
+}
+
+plugins {
+ `kotlin-dsl`
+ `kotlin-dsl-precompiled-script-plugins`
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt
new file mode 100644
index 00000000..193e476d
--- /dev/null
+++ b/buildSrc/src/main/kotlin/deps.kt
@@ -0,0 +1,83 @@
+@file:Suppress("unused", "ClassName", "SpellCheckingInspection")
+
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.kotlin.dsl.project
+import org.gradle.plugin.use.PluginDependenciesSpec
+import org.gradle.plugin.use.PluginDependencySpec
+
+const val ktlintVersion = "0.38.1"
+const val kotlinVersion = "1.4.10"
+
+object appConfig {
+ const val applicationId = "com.hoc.flowmvi"
+
+ const val compileSdkVersion = 30
+ const val buildToolsVersion = "30.0.2"
+
+ const val minSdkVersion = 21
+ const val targetSdkVersion = 30
+ const val versionCode = 1
+ const val versionName = "1.0"
+}
+
+object deps {
+ object androidx {
+ const val appCompat = "androidx.appcompat:appcompat:1.3.0-alpha02"
+ const val coreKtx = "androidx.core:core-ktx:1.5.0-alpha02"
+ const val constraintLayout = "androidx.constraintlayout:constraintlayout:2.0.1"
+ const val recyclerView = "androidx.recyclerview:recyclerview:1.2.0-alpha05"
+ const val swipeRefreshLayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
+ const val material = "com.google.android.material:material:1.3.0-alpha02"
+ }
+
+ object lifecycle {
+ private const val version = "2.3.0-alpha07"
+
+ const val viewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$version" // viewModelScope
+ const val runtimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:$version" // lifecycleScope
+ }
+
+ object squareup {
+ const val retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
+ const val converterMoshi = "com.squareup.retrofit2:converter-moshi:2.9.0"
+ const val loggingInterceptor = "com.squareup.okhttp3:logging-interceptor:4.8.1"
+ const val moshiKotlin = "com.squareup.moshi:moshi-kotlin:1.10.0"
+ }
+
+ object jetbrains {
+ private const val version = "1.3.9"
+
+ const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
+ const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
+ }
+
+ object koin {
+ private const val version = "2.2.0-beta-1"
+
+ const val androidXViewModel = "org.koin:koin-androidx-viewmodel:$version"
+ const val core = "org.koin:koin-core:$version"
+ const val android = "org.koin:koin-android:$version"
+ }
+
+ const val coil = "io.coil-kt:coil:0.11.0"
+
+ object test {
+ const val junit = "junit:junit:4.13"
+ const val androidxJunit = "androidx.test.ext:junit:1.1.2"
+ const val androidXSspresso = "androidx.test.espresso:espresso-core:3.3.0"
+ }
+}
+
+private typealias PDsS = PluginDependenciesSpec
+private typealias PDS = PluginDependencySpec
+
+inline val PDsS.androidApplication: PDS get() = id("com.android.application")
+inline val PDsS.androidLib: PDS get() = id("com.android.library")
+inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android")
+inline val PDsS.kotlin: PDS get() = id("kotlin")
+
+inline val DependencyHandler.domain get() = project(":domain")
+inline val DependencyHandler.core get() = project(":core")
+inline val DependencyHandler.data get() = project(":data")
+inline val DependencyHandler.featureMain get() = project(":feature-main")
+inline val DependencyHandler.featureAdd get() = project(":feature-add")
diff --git a/core/.gitignore b/core/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/core/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
new file mode 100644
index 00000000..4ce71b9a
--- /dev/null
+++ b/core/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ androidLib
+ kotlinAndroid
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+}
+
+dependencies {
+ implementation(deps.jetbrains.coroutinesCore)
+ implementation(deps.jetbrains.coroutinesAndroid)
+
+ implementation(deps.androidx.coreKtx)
+ implementation(deps.androidx.swipeRefreshLayout)
+ implementation(deps.androidx.recyclerView)
+ implementation(deps.androidx.material)
+}
diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /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/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt b/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..e1b1c883
--- /dev/null
+++ b/core/src/androidTest/java/com/hoc/flowmvi/core/ExampleInstrumentedTest.kt
@@ -0,0 +1,21 @@
+package com.hoc.flowmvi.core
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.hoc081098.flowmvi.core.test", appContext.packageName)
+ }
+}
diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..baf271f3
--- /dev/null
+++ b/core/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/hoc/flowmvi/FlowBinding+Exts+Utils.kt b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt
similarity index 77%
rename from app/src/main/java/com/hoc/flowmvi/FlowBinding+Exts+Utils.kt
rename to core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt
index 0bd520e6..881e1fdf 100644
--- a/app/src/main/java/com/hoc/flowmvi/FlowBinding+Exts+Utils.kt
+++ b/core/src/main/java/com/hoc/flowmvi/core/FlowBinding+Exts+Utils.kt
@@ -1,4 +1,4 @@
-package com.hoc.flowmvi
+package com.hoc.flowmvi.core
import android.content.Context
import android.graphics.Canvas
@@ -15,12 +15,28 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import kotlinx.coroutines.*
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.launch
@ExperimentalCoroutinesApi
fun EditText.firstChange(): Flow {
@@ -78,7 +94,7 @@ fun EditText.textChanges(): Flow {
@ExperimentalCoroutinesApi
fun Flow.flatMapFirst(transform: suspend (value: T) -> Flow): Flow =
- map(transform).flattenFirst()
+ map(transform).flattenFirst()
@ExperimentalCoroutinesApi
fun Flow>.flattenFirst(): Flow = channelFlow {
@@ -131,28 +147,28 @@ fun Context.toast(text: CharSequence) = Toast.makeText(this, text, Toast.LENGTH_
@ExperimentalCoroutinesApi
suspend fun main() {
(1..2000).asFlow()
- .onEach { delay(50) }
- .flatMapFirst { v ->
- flow {
- delay(500)
- emit(v)
- }
+ .onEach { delay(50) }
+ .flatMapFirst { v ->
+ flow {
+ delay(500)
+ emit(v)
}
- .onEach { println("[*] $it") }
- .catch { println("Error $it") }
- .collect()
+ }
+ .onEach { println("[*] $it") }
+ .catch { println("Error $it") }
+ .collect()
}
class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback: (Int) -> Unit) :
- ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
+ ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
private val background: ColorDrawable = ColorDrawable(Color.parseColor("#f44336"))
private val iconDelete =
- ContextCompat.getDrawable(context, R.drawable.ic_baseline_delete_white_24)!!
+ ContextCompat.getDrawable(context, R.drawable.ic_baseline_delete_white_24)!!
override fun onMove(
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
) = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
@@ -163,13 +179,13 @@ class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback:
}
override fun onChildDraw(
- c: Canvas,
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- dX: Float,
- dY: Float,
- actionState: Int,
- isCurrentlyActive: Boolean
+ c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float,
+ dY: Float,
+ actionState: Int,
+ isCurrentlyActive: Boolean
) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
val itemView = viewHolder.itemView
@@ -185,10 +201,10 @@ class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback:
iconDelete.setBounds(iconLeft, iconTop, iconRight, iconBottom)
background.setBounds(
- itemView.right + dX.toInt() - 8,
- itemView.top,
- itemView.right,
- itemView.bottom
+ itemView.right + dX.toInt() - 8,
+ itemView.top,
+ itemView.right,
+ itemView.bottom
)
}
else -> background.setBounds(0, 0, 0, 0)
@@ -196,4 +212,4 @@ class SwipeLeftToDeleteCallback(context: Context, private val onSwipedCallback:
background.draw(c)
iconDelete.draw(c)
}
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/Mapper.kt b/core/src/main/java/com/hoc/flowmvi/core/Mapper.kt
new file mode 100644
index 00000000..361a7b2b
--- /dev/null
+++ b/core/src/main/java/com/hoc/flowmvi/core/Mapper.kt
@@ -0,0 +1,3 @@
+package com.hoc.flowmvi.core
+
+typealias Mapper = (T) -> R
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchers.kt b/core/src/main/java/com/hoc/flowmvi/core/dispatchers/CoroutineDispatchers.kt
similarity index 76%
rename from app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchers.kt
rename to core/src/main/java/com/hoc/flowmvi/core/dispatchers/CoroutineDispatchers.kt
index 6fe6c34b..787c0e28 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/dispatchers/CoroutineDispatchers.kt
+++ b/core/src/main/java/com/hoc/flowmvi/core/dispatchers/CoroutineDispatchers.kt
@@ -1,8 +1,8 @@
-package com.hoc.flowmvi.domain.dispatchers
+package com.hoc.flowmvi.core.dispatchers
import kotlinx.coroutines.CoroutineDispatcher
interface CoroutineDispatchers {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
-}
\ No newline at end of file
+}
diff --git a/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt b/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt
new file mode 100644
index 00000000..2846ce6d
--- /dev/null
+++ b/core/src/main/java/com/hoc/flowmvi/core/navigator/Navigator.kt
@@ -0,0 +1,14 @@
+package com.hoc.flowmvi.core.navigator
+
+import android.content.Context
+import android.content.Intent
+
+interface IntentProviders {
+ interface Add {
+ fun makeIntent(context: Context): Intent
+ }
+}
+
+interface Navigator {
+ fun Context.navigateToAdd()
+}
diff --git a/app/src/main/res/drawable/ic_baseline_delete_white_24.xml b/core/src/main/res/drawable/ic_baseline_delete_white_24.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_baseline_delete_white_24.xml
rename to core/src/main/res/drawable/ic_baseline_delete_white_24.xml
diff --git a/app/src/main/res/font/noto_sans.xml b/core/src/main/res/font/noto_sans.xml
similarity index 100%
rename from app/src/main/res/font/noto_sans.xml
rename to core/src/main/res/font/noto_sans.xml
diff --git a/app/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml
similarity index 100%
rename from app/src/main/res/values/colors.xml
rename to core/src/main/res/values/colors.xml
diff --git a/app/src/main/res/values/font_certs.xml b/core/src/main/res/values/font_certs.xml
similarity index 100%
rename from app/src/main/res/values/font_certs.xml
rename to core/src/main/res/values/font_certs.xml
diff --git a/app/src/main/res/values/preloaded_fonts.xml b/core/src/main/res/values/preloaded_fonts.xml
similarity index 100%
rename from app/src/main/res/values/preloaded_fonts.xml
rename to core/src/main/res/values/preloaded_fonts.xml
diff --git a/app/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
similarity index 100%
rename from app/src/main/res/values/strings.xml
rename to core/src/main/res/values/strings.xml
diff --git a/app/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml
similarity index 100%
rename from app/src/main/res/values/styles.xml
rename to core/src/main/res/values/styles.xml
diff --git a/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt b/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt
new file mode 100644
index 00000000..41038a9e
--- /dev/null
+++ b/core/src/test/java/com/hoc/flowmvi/core/ExampleUnitTest.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.core
+
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/data/.gitignore b/data/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/data/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
new file mode 100644
index 00000000..d32375fe
--- /dev/null
+++ b/data/build.gradle.kts
@@ -0,0 +1,48 @@
+plugins {
+ androidLib
+ kotlinAndroid
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+}
+
+dependencies {
+ implementation(core)
+ implementation(domain)
+
+ implementation(deps.jetbrains.coroutinesCore)
+
+ implementation(deps.squareup.retrofit)
+ implementation(deps.squareup.moshiKotlin)
+ implementation(deps.squareup.converterMoshi)
+ implementation(deps.squareup.loggingInterceptor)
+
+ implementation(deps.koin.core)
+}
diff --git a/data/consumer-rules.pro b/data/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/data/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/data/src/androidTest/java/com/hoc/flowmvi/data/ExampleInstrumentedTest.kt b/data/src/androidTest/java/com/hoc/flowmvi/data/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..6fca04ce
--- /dev/null
+++ b/data/src/androidTest/java/com/hoc/flowmvi/data/ExampleInstrumentedTest.kt
@@ -0,0 +1,21 @@
+package com.hoc.flowmvi.data
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.hoc.flowmvi.data.test", appContext.packageName)
+ }
+}
diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..fe5edcd8
--- /dev/null
+++ b/data/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/java/com/hoc/flowmvi/koin/DataModule.kt b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
similarity index 56%
rename from app/src/main/java/com/hoc/flowmvi/koin/DataModule.kt
rename to data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
index 621d97d1..d3df8393 100644
--- a/app/src/main/java/com/hoc/flowmvi/koin/DataModule.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/DataModule.kt
@@ -1,12 +1,15 @@
-package com.hoc.flowmvi.koin
+package com.hoc.flowmvi.data
-import com.hoc.flowmvi.BuildConfig
import com.hoc.flowmvi.data.mapper.UserDomainToUserBodyMapper
import com.hoc.flowmvi.data.mapper.UserDomainToUserResponseMapper
import com.hoc.flowmvi.data.mapper.UserResponseToUserDomainMapper
import com.hoc.flowmvi.data.remote.UserApiService
+import com.hoc.flowmvi.domain.repository.UserRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
@@ -14,18 +17,19 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
-import java.util.concurrent.TimeUnit
private const val BASE_URL = "BASE_URL"
+@ExperimentalCoroutinesApi
+@FlowPreview
val dataModule = module {
single { UserApiService(retrofit = get()) }
single {
provideRetrofit(
- baseUrl = get(named(BASE_URL)),
- moshi = get(),
- client = get()
+ baseUrl = get(named(BASE_URL)),
+ moshi = get(),
+ client = get()
)
}
@@ -40,31 +44,41 @@ val dataModule = module {
factory { UserDomainToUserResponseMapper() }
factory { UserDomainToUserBodyMapper() }
+
+ single {
+ UserRepositoryImpl(
+ userApiService = get(),
+ dispatchers = get(),
+ responseToDomain = get(),
+ domainToResponse = get(),
+ domainToBody = get()
+ )
+ }
}
private fun provideMoshi(): Moshi {
return Moshi
- .Builder()
- .add(KotlinJsonAdapterFactory())
- .build()
+ .Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
}
private fun provideRetrofit(baseUrl: String, moshi: Moshi, client: OkHttpClient): Retrofit {
return Retrofit.Builder()
- .client(client)
- .addConverterFactory(MoshiConverterFactory.create(moshi))
- .baseUrl(baseUrl)
- .build()
+ .client(client)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .baseUrl(baseUrl)
+ .build()
}
private fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
- .connectTimeout(10, TimeUnit.SECONDS)
- .readTimeout(10, TimeUnit.SECONDS)
- .writeTimeout(10, TimeUnit.SECONDS)
- .addInterceptor(
- HttpLoggingInterceptor()
- .apply { level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE }
- )
- .build()
-}
\ No newline at end of file
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(10, TimeUnit.SECONDS)
+ .addInterceptor(
+ HttpLoggingInterceptor()
+ .apply { level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE }
+ )
+ .build()
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
similarity index 58%
rename from app/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
rename to data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
index 9c742edd..14466854 100644
--- a/app/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt
@@ -1,11 +1,11 @@
package com.hoc.flowmvi.data
import android.util.Log
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.core.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.data.remote.UserApiService
import com.hoc.flowmvi.data.remote.UserBody
import com.hoc.flowmvi.data.remote.UserResponse
-import com.hoc.flowmvi.domain.Mapper
-import com.hoc.flowmvi.domain.dispatchers.CoroutineDispatchers
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.repository.UserRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -13,16 +13,21 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.withContext
@ExperimentalCoroutinesApi
-class UserRepositoryImpl(
- private val userApiService: UserApiService,
- private val dispatchers: CoroutineDispatchers,
- private val responseToDomain: Mapper,
- private val domainToResponse: Mapper,
- private val domainToBody: Mapper
+internal class UserRepositoryImpl constructor(
+ private val userApiService: UserApiService,
+ private val dispatchers: CoroutineDispatchers,
+ private val responseToDomain: Mapper,
+ private val domainToResponse: Mapper,
+ private val domainToBody: Mapper
) : UserRepository {
private sealed class Change {
@@ -45,22 +50,22 @@ class UserRepositoryImpl(
val initial = getUsersFromRemote()
changesChannel
- .asFlow()
- .onEach { Log.d("###", "[USER_REPO] Change=$it") }
- .scan(initial) { acc, change ->
- when (change) {
- is Change.Removed -> acc.filter { it.id != change.removed.id }
- is Change.Refreshed -> change.user
- is Change.Added -> acc + change.user
- }
+ .asFlow()
+ .onEach { Log.d("###", "[USER_REPO] Change=$it") }
+ .scan(initial) { acc, change ->
+ when (change) {
+ is Change.Removed -> acc.filter { it.id != change.removed.id }
+ is Change.Refreshed -> change.user
+ is Change.Added -> acc + change.user
}
- .onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
- .let { emitAll(it) }
+ }
+ .onEach { Log.d("###", "[USER_REPO] Emit users.size=${it.size} ") }
+ .let { emitAll(it) }
}
}
override suspend fun refresh() =
- getUsersFromRemote().let { changesChannel.send(Change.Refreshed(it)) }
+ getUsersFromRemote().let { changesChannel.send(Change.Refreshed(it)) }
override suspend fun remove(user: User) {
withContext(dispatchers.io) {
@@ -80,8 +85,8 @@ class UserRepositoryImpl(
companion object {
private val avatarUrls =
- (0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
- (0 until 100).map { "https://randomuser.me/api/portraits/women/$it.jpg" } +
- (0 until 10).map { "https://randomuser.me/api/portraits/lego/$it.jpg" }
+ (0 until 100).map { "https://randomuser.me/api/portraits/men/$it.jpg" } +
+ (0 until 100).map { "https://randomuser.me/api/portraits/women/$it.jpg" } +
+ (0 until 10).map { "https://randomuser.me/api/portraits/lego/$it.jpg" }
}
-}
\ No newline at end of file
+}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
new file mode 100644
index 00000000..86227ca7
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserBodyMapper.kt
@@ -0,0 +1,16 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.data.remote.UserBody
+import com.hoc.flowmvi.domain.entity.User
+
+internal class UserDomainToUserBodyMapper : Mapper {
+ override fun invoke(domain: User): UserBody {
+ return UserBody(
+ email = domain.email,
+ avatar = domain.avatar,
+ firstName = domain.firstName,
+ lastName = domain.lastName
+ )
+ }
+}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
new file mode 100644
index 00000000..b25e8039
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserDomainToUserResponseMapper.kt
@@ -0,0 +1,17 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.data.remote.UserResponse
+import com.hoc.flowmvi.domain.entity.User
+
+internal class UserDomainToUserResponseMapper : Mapper {
+ override fun invoke(domain: User): UserResponse {
+ return UserResponse(
+ id = domain.id,
+ avatar = domain.avatar,
+ email = domain.email,
+ firstName = domain.firstName,
+ lastName = domain.lastName
+ )
+ }
+}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt
new file mode 100644
index 00000000..f8827cbf
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt
@@ -0,0 +1,17 @@
+package com.hoc.flowmvi.data.mapper
+
+import com.hoc.flowmvi.core.Mapper
+import com.hoc.flowmvi.data.remote.UserResponse
+import com.hoc.flowmvi.domain.entity.User
+
+internal class UserResponseToUserDomainMapper : Mapper {
+ override fun invoke(response: UserResponse): User {
+ return User(
+ id = response.id,
+ avatar = response.avatar,
+ email = response.email,
+ firstName = response.firstName,
+ lastName = response.lastName
+ )
+ }
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
similarity index 69%
rename from app/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
rename to data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
index 5268c4cf..c355bb35 100644
--- a/app/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/UserApiService.kt
@@ -2,9 +2,14 @@ package com.hoc.flowmvi.data.remote
import retrofit2.Retrofit
import retrofit2.create
-import retrofit2.http.*
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Path
-interface UserApiService {
+internal interface UserApiService {
@GET("users")
suspend fun getUsers(): List
@@ -18,4 +23,4 @@ interface UserApiService {
companion object {
operator fun invoke(retrofit: Retrofit) = retrofit.create()
}
-}
\ No newline at end of file
+}
diff --git a/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
new file mode 100644
index 00000000..1cc94e54
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/UserBody.kt
@@ -0,0 +1,14 @@
+package com.hoc.flowmvi.data.remote
+
+import com.squareup.moshi.Json
+
+internal data class UserBody(
+ @Json(name = "email")
+ val email: String,
+ @Json(name = "first_name")
+ val firstName: String,
+ @Json(name = "last_name")
+ val lastName: String,
+ @Json(name = "avatar")
+ val avatar: String
+)
diff --git a/data/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt b/data/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt
new file mode 100644
index 00000000..4ba27a48
--- /dev/null
+++ b/data/src/main/java/com/hoc/flowmvi/data/remote/UserResponse.kt
@@ -0,0 +1,16 @@
+package com.hoc.flowmvi.data.remote
+
+import com.squareup.moshi.Json
+
+internal data class UserResponse(
+ @Json(name = "_id")
+ val id: String,
+ @Json(name = "email")
+ val email: String,
+ @Json(name = "first_name")
+ val firstName: String,
+ @Json(name = "last_name")
+ val lastName: String,
+ @Json(name = "avatar")
+ val avatar: String
+)
diff --git a/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt b/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt
new file mode 100644
index 00000000..f160a149
--- /dev/null
+++ b/data/src/test/java/com/hoc/flowmvi/data/ExampleUnitTest.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.data
+
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/domain/.gitignore b/domain/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/domain/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
new file mode 100644
index 00000000..6cc7bff5
--- /dev/null
+++ b/domain/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ kotlin
+}
+
+dependencies {
+ implementation(deps.jetbrains.coroutinesCore)
+ implementation(deps.koin.core)
+}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt b/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
new file mode 100644
index 00000000..93322268
--- /dev/null
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/DomainModule.kt
@@ -0,0 +1,17 @@
+package com.hoc.flowmvi.domain
+
+import com.hoc.flowmvi.domain.usecase.AddUserUseCase
+import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
+import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase
+import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
+import org.koin.dsl.module
+
+val domainModule = module {
+ factory { GetUsersUseCase(userRepository = get()) }
+
+ factory { RefreshGetUsersUseCase(userRepository = get()) }
+
+ factory { RemoveUserUseCase(userRepository = get()) }
+
+ factory { AddUserUseCase(userRepository = get()) }
+}
diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt
new file mode 100644
index 00000000..63d0b6be
--- /dev/null
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/entity/User.kt
@@ -0,0 +1,9 @@
+package com.hoc.flowmvi.domain.entity
+
+data class User(
+ val id: String,
+ val email: String,
+ val firstName: String,
+ val lastName: String,
+ val avatar: String
+)
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
similarity index 99%
rename from app/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
rename to domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
index 073ea1f7..16fc7496 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/repository/UserRepository.kt
@@ -11,4 +11,4 @@ interface UserRepository {
suspend fun remove(user: User)
suspend fun add(user: User)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
similarity index 99%
rename from app/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
rename to domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
index e25832bd..d92959e4 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/AddUserUseCase.kt
@@ -5,4 +5,4 @@ import com.hoc.flowmvi.domain.repository.UserRepository
class AddUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(user: User) = userRepository.add(user)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
similarity index 99%
rename from app/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
rename to domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
index 51060de9..553f7a30 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/GetUsersUseCase.kt
@@ -4,4 +4,4 @@ import com.hoc.flowmvi.domain.repository.UserRepository
class GetUsersUseCase(private val userRepository: UserRepository) {
operator fun invoke() = userRepository.getUsers()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
similarity index 99%
rename from app/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
rename to domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
index e9feb515..ba6de220 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RefreshGetUsersUseCase.kt
@@ -4,4 +4,4 @@ import com.hoc.flowmvi.domain.repository.UserRepository
class RefreshGetUsersUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke() = userRepository.refresh()
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
similarity index 99%
rename from app/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
rename to domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
index 09f52799..2ffa6c7a 100644
--- a/app/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
+++ b/domain/src/main/java/com/hoc/flowmvi/domain/usecase/RemoveUserUseCase.kt
@@ -5,4 +5,4 @@ import com.hoc.flowmvi.domain.repository.UserRepository
class RemoveUserUseCase(private val userRepository: UserRepository) {
suspend operator fun invoke(user: User) = userRepository.remove(user)
-}
\ No newline at end of file
+}
diff --git a/feature-add/.gitignore b/feature-add/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/feature-add/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/feature-add/build.gradle.kts b/feature-add/build.gradle.kts
new file mode 100644
index 00000000..0d7f4a7f
--- /dev/null
+++ b/feature-add/build.gradle.kts
@@ -0,0 +1,53 @@
+plugins {
+ androidLib
+ kotlinAndroid
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+
+ buildFeatures { viewBinding = true }
+}
+
+dependencies {
+ implementation(domain)
+ implementation(core)
+
+ implementation(deps.androidx.appCompat)
+ implementation(deps.androidx.coreKtx)
+
+ implementation(deps.lifecycle.viewModelKtx)
+ implementation(deps.lifecycle.runtimeKtx)
+
+ implementation(deps.androidx.constraintLayout)
+ implementation(deps.androidx.material)
+
+ implementation(deps.jetbrains.coroutinesCore)
+ implementation(deps.koin.androidXViewModel)
+}
diff --git a/feature-add/consumer-rules.pro b/feature-add/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature-add/proguard-rules.pro b/feature-add/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/feature-add/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/feature-add/src/androidTest/java/com/hoc/flowmvi/ui/add/ExampleInstrumentedTest.kt b/feature-add/src/androidTest/java/com/hoc/flowmvi/ui/add/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..55d6c3e9
--- /dev/null
+++ b/feature-add/src/androidTest/java/com/hoc/flowmvi/ui/add/ExampleInstrumentedTest.kt
@@ -0,0 +1,21 @@
+package com.hoc.flowmvi.ui.add
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.hoc.flowmvi.ui.add.test", appContext.packageName)
+ }
+}
diff --git a/feature-add/src/main/AndroidManifest.xml b/feature-add/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..de206c62
--- /dev/null
+++ b/feature-add/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
similarity index 58%
rename from app/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
rename to feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
index 9529d1b8..26095623 100644
--- a/app/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt
@@ -1,5 +1,7 @@
package com.hoc.flowmvi.ui.add
+import android.content.Context
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
@@ -8,20 +10,26 @@ import androidx.core.view.isInvisible
import androidx.lifecycle.lifecycleScope
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
-import com.hoc.flowmvi.clicks
-import com.hoc.flowmvi.databinding.ActivityAddBinding
-import com.hoc.flowmvi.firstChange
-import com.hoc.flowmvi.textChanges
-import com.hoc.flowmvi.toast
-import com.hoc.flowmvi.ui.add.AddContract.*
+import com.hoc.flowmvi.core.clicks
+import com.hoc.flowmvi.core.firstChange
+import com.hoc.flowmvi.core.navigator.IntentProviders
+import com.hoc.flowmvi.core.textChanges
+import com.hoc.flowmvi.core.toast
+import com.hoc.flowmvi.ui.add.databinding.ActivityAddBinding
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
import org.koin.androidx.viewmodel.ext.android.viewModel
@FlowPreview
@ExperimentalCoroutinesApi
-class AddActivity : AppCompatActivity(), View {
+class AddActivity : AppCompatActivity() {
private val addVM by viewModel()
private val addBinding by lazy { ActivityAddBinding.inflate(layoutInflater) }
@@ -45,21 +53,21 @@ class AddActivity : AppCompatActivity(), View {
// observe view model
lifecycleScope.launchWhenStarted {
addVM.viewState
- .onEach { render(it) }
- .catch { }
- .collect()
+ .onEach { render(it) }
+ .catch { }
+ .collect()
}
lifecycleScope.launchWhenStarted {
addVM.singleEvent
- .onEach { handleSingleEvent(it) }
- .catch { }
- .collect()
+ .onEach { handleSingleEvent(it) }
+ .catch { }
+ .collect()
}
// pass view intent to view model
intents()
- .onEach { addVM.processIntent(it) }
- .launchIn(lifecycleScope)
+ .onEach { addVM.processIntent(it) }
+ .launchIn(lifecycleScope)
}
private fun handleSingleEvent(event: SingleEvent) {
@@ -108,11 +116,11 @@ class AddActivity : AppCompatActivity(), View {
}
TransitionManager.beginDelayedTransition(
- addBinding.root,
- AutoTransition()
- .addTarget(addBinding.progressBar)
- .addTarget(addBinding.addButton)
- .setDuration(200)
+ addBinding.root,
+ AutoTransition()
+ .addTarget(addBinding.progressBar)
+ .addTarget(addBinding.addButton)
+ .setDuration(200)
)
addBinding.progressBar.isInvisible = !viewState.isLoading
addBinding.addButton.isInvisible = viewState.isLoading
@@ -120,35 +128,42 @@ class AddActivity : AppCompatActivity(), View {
private fun setupViews() = Unit
- override fun intents(): Flow = addBinding.run {
+ private fun intents(): Flow = addBinding.run {
merge(
- emailEditText
- .editText!!
- .textChanges()
- .map { ViewIntent.EmailChanged(it?.toString()) },
- firstNameEditText
- .editText!!
- .textChanges()
- .map { ViewIntent.FirstNameChanged(it?.toString()) },
- lastNameEditText
- .editText!!
- .textChanges()
- .map { ViewIntent.LastNameChanged(it?.toString()) },
- addButton
- .clicks()
- .map { ViewIntent.Submit },
- emailEditText
- .editText!!
- .firstChange()
- .map { ViewIntent.EmailChangedFirstTime },
- firstNameEditText
- .editText!!
- .firstChange()
- .map { ViewIntent.FirstNameChangedFirstTime },
- lastNameEditText
- .editText!!
- .firstChange()
- .map { ViewIntent.LastNameChangedFirstTime },
+ emailEditText
+ .editText!!
+ .textChanges()
+ .map { ViewIntent.EmailChanged(it?.toString()) },
+ firstNameEditText
+ .editText!!
+ .textChanges()
+ .map { ViewIntent.FirstNameChanged(it?.toString()) },
+ lastNameEditText
+ .editText!!
+ .textChanges()
+ .map { ViewIntent.LastNameChanged(it?.toString()) },
+ addButton
+ .clicks()
+ .map { ViewIntent.Submit },
+ emailEditText
+ .editText!!
+ .firstChange()
+ .map { ViewIntent.EmailChangedFirstTime },
+ firstNameEditText
+ .editText!!
+ .firstChange()
+ .map { ViewIntent.FirstNameChangedFirstTime },
+ lastNameEditText
+ .editText!!
+ .firstChange()
+ .map { ViewIntent.LastNameChangedFirstTime },
)
}
-}
\ No newline at end of file
+
+ @ExperimentalCoroutinesApi
+ @FlowPreview
+ internal class IntentProvider : IntentProviders.Add {
+ override fun makeIntent(context: Context): Intent =
+ Intent(context, AddActivity::class.java)
+ }
+}
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
new file mode 100644
index 00000000..f3d638a5
--- /dev/null
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddContract.kt
@@ -0,0 +1,80 @@
+package com.hoc.flowmvi.ui.add
+
+import com.hoc.flowmvi.domain.entity.User
+
+internal enum class ValidationError {
+ INVALID_EMAIL_ADDRESS,
+ TOO_SHORT_FIRST_NAME,
+ TOO_SHORT_LAST_NAME
+}
+
+internal data class ViewState(
+ val errors: Set,
+ val isLoading: Boolean,
+ val emailChanged: Boolean,
+ val firstNameChanged: Boolean,
+ val lastNameChanged: Boolean,
+) {
+ companion object {
+ fun initial() = ViewState(
+ errors = emptySet(),
+ isLoading = false,
+ emailChanged = false,
+ firstNameChanged = false,
+ lastNameChanged = false,
+ )
+ }
+}
+
+internal sealed class ViewIntent {
+ data class EmailChanged(val email: String?) : ViewIntent()
+ data class FirstNameChanged(val firstName: String?) : ViewIntent()
+ data class LastNameChanged(val lastName: String?) : ViewIntent()
+
+ object Submit : ViewIntent()
+
+ object EmailChangedFirstTime : ViewIntent()
+ object FirstNameChangedFirstTime : ViewIntent()
+ object LastNameChangedFirstTime : ViewIntent()
+}
+
+internal sealed class PartialStateChange {
+ abstract fun reduce(viewState: ViewState): ViewState
+
+ data class ErrorsChanged(val errors: Set) : PartialStateChange() {
+ override fun reduce(viewState: ViewState) = viewState.copy(errors = errors)
+ }
+
+ sealed class AddUser : PartialStateChange() {
+ object Loading : AddUser()
+ data class AddUserSuccess(val user: User) : AddUser()
+ data class AddUserFailure(val user: User, val throwable: Throwable) : AddUser()
+
+ override fun reduce(viewState: ViewState): ViewState {
+ return when (this) {
+ Loading -> viewState.copy(isLoading = true)
+ is AddUserSuccess -> viewState.copy(isLoading = false)
+ is AddUserFailure -> viewState.copy(isLoading = false)
+ }
+ }
+ }
+
+ sealed class FirstChange : PartialStateChange() {
+ object EmailChangedFirstTime : FirstChange()
+ object FirstNameChangedFirstTime : FirstChange()
+ object LastNameChangedFirstTime : FirstChange()
+
+ override fun reduce(viewState: ViewState): ViewState {
+ return when (this) {
+ EmailChangedFirstTime -> viewState.copy(emailChanged = true)
+ FirstNameChangedFirstTime -> viewState.copy(firstNameChanged = true)
+ LastNameChangedFirstTime -> viewState.copy(lastNameChanged = true)
+ }
+ }
+ }
+}
+
+internal sealed class SingleEvent {
+ data class AddUserSuccess(val user: User) : SingleEvent()
+ data class AddUserFailure(val user: User, val throwable: Throwable) : SingleEvent()
+}
diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt
new file mode 100644
index 00000000..8e7b45ef
--- /dev/null
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddModule.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.ui.add
+
+import com.hoc.flowmvi.core.navigator.IntentProviders
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+
+@ExperimentalCoroutinesApi
+@FlowPreview
+val addModule = module {
+ viewModel { AddVM(addUser = get()) }
+
+ single { AddActivity.IntentProvider() }
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
similarity index 54%
rename from app/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
rename to feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
index 5b36e5de..40e05f65 100644
--- a/app/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
+++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt
@@ -3,20 +3,33 @@ package com.hoc.flowmvi.ui.add
import androidx.core.util.PatternsCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.hoc.flowmvi.core.flatMapFirst
+import com.hoc.flowmvi.core.withLatestFrom
import com.hoc.flowmvi.domain.entity.User
import com.hoc.flowmvi.domain.usecase.AddUserUseCase
-import com.hoc.flowmvi.flatMapFirst
-import com.hoc.flowmvi.ui.add.AddContract.*
-import com.hoc.flowmvi.withLatestFrom
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.scan
@FlowPreview
@ExperimentalCoroutinesApi
-class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
+internal class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
private val _eventChannel = BroadcastChannel(capacity = Channel.BUFFERED)
private val _intentChannel = BroadcastChannel(capacity = Channel.BUFFERED)
@@ -33,13 +46,13 @@ class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
singleEvent = _eventChannel.asFlow()
_intentChannel
- .asFlow()
- .toPartialStateChangesFlow()
- .sendSingleEvent()
- .scan(initialVS) { state, change -> change.reduce(state) }
- .onEach { viewState.value = it }
- .catch { }
- .launchIn(viewModelScope)
+ .asFlow()
+ .toPartialStateChangesFlow()
+ .sendSingleEvent()
+ .scan(initialVS) { state, change -> change.reduce(state) }
+ .onEach { viewState.value = it }
+ .catch { }
+ .launchIn(viewModelScope)
}
private fun Flow.sendSingleEvent(): Flow {
@@ -49,8 +62,8 @@ class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
PartialStateChange.AddUser.Loading -> return@onEach
is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(change.user)
is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure(
- change.user,
- change.throwable
+ change.user,
+ change.throwable
)
PartialStateChange.FirstChange.EmailChangedFirstTime -> return@onEach
PartialStateChange.FirstChange.FirstNameChangedFirstTime -> return@onEach
@@ -62,60 +75,60 @@ class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
private fun Flow.toPartialStateChangesFlow(): Flow {
val emailErrors = filterIsInstance()
- .map { it.email }
- .map { validateEmail(it) to it }
+ .map { it.email }
+ .map { validateEmail(it) to it }
val firstNameErrors = filterIsInstance()
- .map { it.firstName }
- .map { validateFirstName(it) to it }
+ .map { it.firstName }
+ .map { validateFirstName(it) to it }
val lastNameErrors = filterIsInstance()
- .map { it.lastName }
- .map { validateLastName(it) to it }
+ .map { it.lastName }
+ .map { validateLastName(it) to it }
val userFormFlow =
- combine(emailErrors, firstNameErrors, lastNameErrors) { email, firstName, lastName ->
- UserForm(
- errors = email.first + firstName.first + lastName.first,
- user = User(
- firstName = firstName.second ?: "",
- email = email.second ?: "",
- lastName = lastName.second ?: "",
- id = "",
- avatar = ""
- )
+ combine(emailErrors, firstNameErrors, lastNameErrors) { email, firstName, lastName ->
+ UserForm(
+ errors = email.first + firstName.first + lastName.first,
+ user = User(
+ firstName = firstName.second ?: "",
+ email = email.second ?: "",
+ lastName = lastName.second ?: "",
+ id = "",
+ avatar = ""
)
- }
+ )
+ }
val addUserChanges = filterIsInstance()
- .withLatestFrom(userFormFlow) { _, userForm -> userForm }
- .filter { it.errors.isEmpty() }
- .map { it.user }
- .flatMapFirst { user ->
- flow { emit(addUser(user)) }
- .map {
- @Suppress("USELESS_CAST")
- PartialStateChange.AddUser.AddUserSuccess(user) as PartialStateChange.AddUser
- }
- .onStart { emit(PartialStateChange.AddUser.Loading) }
- .catch { emit(PartialStateChange.AddUser.AddUserFailure(user, it)) }
- }
+ .withLatestFrom(userFormFlow) { _, userForm -> userForm }
+ .filter { it.errors.isEmpty() }
+ .map { it.user }
+ .flatMapFirst { user ->
+ flow { emit(addUser(user)) }
+ .map {
+ @Suppress("USELESS_CAST")
+ PartialStateChange.AddUser.AddUserSuccess(user) as PartialStateChange.AddUser
+ }
+ .onStart { emit(PartialStateChange.AddUser.Loading) }
+ .catch { emit(PartialStateChange.AddUser.AddUserFailure(user, it)) }
+ }
val firstChanges = merge(
- filterIsInstance()
- .map { PartialStateChange.FirstChange.EmailChangedFirstTime },
- filterIsInstance()
- .map { PartialStateChange.FirstChange.FirstNameChangedFirstTime },
- filterIsInstance()
- .map { PartialStateChange.FirstChange.LastNameChangedFirstTime }
+ filterIsInstance()
+ .map { PartialStateChange.FirstChange.EmailChangedFirstTime },
+ filterIsInstance()
+ .map { PartialStateChange.FirstChange.FirstNameChangedFirstTime },
+ filterIsInstance()
+ .map { PartialStateChange.FirstChange.LastNameChangedFirstTime }
)
return merge(
- userFormFlow
- .map { it.errors }
- .map { PartialStateChange.ErrorsChanged(it) },
- addUserChanges,
- firstChanges,
+ userFormFlow
+ .map { it.errors }
+ .map { PartialStateChange.ErrorsChanged(it) },
+ addUserChanges,
+ firstChanges,
)
}
@@ -124,8 +137,8 @@ class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
const val MIN_LENGTH_LAST_NAME = 3
private data class UserForm(
- val errors: Set,
- val user: User
+ val errors: Set,
+ val user: User
)
fun validateFirstName(firstName: String?): Set {
@@ -165,5 +178,3 @@ class AddVM(private val addUser: AddUserUseCase) : ViewModel() {
}
}
}
-
-
diff --git a/app/src/main/res/layout/activity_add.xml b/feature-add/src/main/res/layout/activity_add.xml
similarity index 99%
rename from app/src/main/res/layout/activity_add.xml
rename to feature-add/src/main/res/layout/activity_add.xml
index ab213994..2af2cd92 100644
--- a/app/src/main/res/layout/activity_add.xml
+++ b/feature-add/src/main/res/layout/activity_add.xml
@@ -127,4 +127,4 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/addButton" />
-
\ No newline at end of file
+
diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/ExampleUnitTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/ExampleUnitTest.kt
new file mode 100644
index 00000000..8b9356a7
--- /dev/null
+++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/ExampleUnitTest.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.ui.add
+
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/feature-main/.gitignore b/feature-main/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/feature-main/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/feature-main/build.gradle.kts b/feature-main/build.gradle.kts
new file mode 100644
index 00000000..6ec302c8
--- /dev/null
+++ b/feature-main/build.gradle.kts
@@ -0,0 +1,56 @@
+plugins {
+ androidLib
+ kotlinAndroid
+}
+
+android {
+ compileSdkVersion(appConfig.compileSdkVersion)
+ buildToolsVersion(appConfig.buildToolsVersion)
+
+ defaultConfig {
+ minSdkVersion(appConfig.minSdkVersion)
+ targetSdkVersion(appConfig.targetSdkVersion)
+ versionCode = appConfig.versionCode
+ versionName = appConfig.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() }
+
+ buildFeatures { viewBinding = true }
+}
+
+dependencies {
+ implementation(domain)
+ implementation(core)
+
+ implementation(deps.androidx.appCompat)
+ implementation(deps.androidx.coreKtx)
+
+ implementation(deps.lifecycle.viewModelKtx)
+ implementation(deps.lifecycle.runtimeKtx)
+
+ implementation(deps.androidx.recyclerView)
+ implementation(deps.androidx.constraintLayout)
+ implementation(deps.androidx.swipeRefreshLayout)
+ implementation(deps.androidx.material)
+
+ implementation(deps.jetbrains.coroutinesCore)
+ implementation(deps.koin.androidXViewModel)
+ implementation(deps.coil)
+}
diff --git a/feature-main/consumer-rules.pro b/feature-main/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature-main/proguard-rules.pro b/feature-main/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/feature-main/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/feature-main/src/androidTest/java/com/hoc/flowmvi/ui/ExampleInstrumentedTest.kt b/feature-main/src/androidTest/java/com/hoc/flowmvi/ui/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..22f7f20b
--- /dev/null
+++ b/feature-main/src/androidTest/java/com/hoc/flowmvi/ui/ExampleInstrumentedTest.kt
@@ -0,0 +1,21 @@
+package com.hoc.flowmvi.ui
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.hoc.flowmvi.feature_home.test", appContext.packageName)
+ }
+}
diff --git a/feature-main/src/main/AndroidManifest.xml b/feature-main/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..24c20b8d
--- /dev/null
+++ b/feature-main/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
similarity index 68%
rename from app/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
rename to feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
index 9f0e7017..9e078fd9 100644
--- a/app/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainActivity.kt
@@ -1,6 +1,5 @@
package com.hoc.flowmvi.ui.main
-import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
@@ -12,27 +11,37 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
-import com.hoc.flowmvi.*
-import com.hoc.flowmvi.databinding.ActivityMainBinding
-import com.hoc.flowmvi.ui.add.AddActivity
-import com.hoc.flowmvi.ui.main.MainContract.*
+import com.hoc.flowmvi.core.SwipeLeftToDeleteCallback
+import com.hoc.flowmvi.core.clicks
+import com.hoc.flowmvi.core.navigator.Navigator
+import com.hoc.flowmvi.core.refreshes
+import com.hoc.flowmvi.core.toast
+import com.hoc.flowmvi.ui.main.databinding.ActivityMainBinding
+import kotlin.LazyThreadSafetyMode.NONE
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.consumeAsFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
-import kotlin.LazyThreadSafetyMode.NONE
@FlowPreview
@ExperimentalCoroutinesApi
-class MainActivity : AppCompatActivity(), View {
+class MainActivity : AppCompatActivity() {
private val mainVM by viewModel()
+ private val navigator by inject()
private val userAdapter = UserAdapter()
private val mainBinding by lazy(NONE) { ActivityMainBinding.inflate(layoutInflater) }
- private val removeChannel = BroadcastChannel(Channel.BUFFERED)
+ private val removeChannel = Channel(Channel.BUFFERED)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -45,7 +54,7 @@ class MainActivity : AppCompatActivity(), View {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_action -> {
- startActivity(Intent(this, AddActivity::class.java))
+ navigator.run { navigateToAdd() }
true
}
else -> super.onOptionsItemSelected(item)
@@ -65,10 +74,10 @@ class MainActivity : AppCompatActivity(), View {
addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
ItemTouchHelper(
- SwipeLeftToDeleteCallback(context) cb@{ position ->
- val userItem = mainVM.viewState.value.userItems[position]
- removeChannel.offer(userItem)
- }
+ SwipeLeftToDeleteCallback(context) cb@{ position ->
+ val userItem = mainVM.viewState.value.userItems[position]
+ removeChannel.offer(userItem)
+ }
).attachToRecyclerView(this)
}
}
@@ -77,28 +86,28 @@ class MainActivity : AppCompatActivity(), View {
// observe view model
lifecycleScope.launchWhenStarted {
mainVM.viewState
- .onEach { render(it) }
- .catch { }
- .collect()
+ .onEach { render(it) }
+ .catch { }
+ .collect()
}
lifecycleScope.launchWhenStarted {
mainVM.singleEvent
- .onEach { handleSingleEvent(it) }
- .catch { }
- .collect()
+ .onEach { handleSingleEvent(it) }
+ .catch { }
+ .collect()
}
// pass view intent to view model
intents()
- .onEach { mainVM.processIntent(it) }
- .launchIn(lifecycleScope)
+ .onEach { mainVM.processIntent(it) }
+ .launchIn(lifecycleScope)
}
- override fun intents() = merge(
- flowOf(ViewIntent.Initial),
- mainBinding.swipeRefreshLayout.refreshes().map { ViewIntent.Refresh },
- mainBinding.retryButton.clicks().map { ViewIntent.Retry },
- removeChannel.asFlow().map { ViewIntent.RemoveUser(it) }
+ private fun intents() = merge(
+ flowOf(ViewIntent.Initial),
+ mainBinding.swipeRefreshLayout.refreshes().map { ViewIntent.Refresh },
+ mainBinding.retryButton.clicks().map { ViewIntent.Retry },
+ removeChannel.consumeAsFlow().map { ViewIntent.RemoveUser(it) }
)
private fun handleSingleEvent(event: SingleEvent) {
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
new file mode 100644
index 00000000..c91a89d9
--- /dev/null
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainContract.kt
@@ -0,0 +1,115 @@
+package com.hoc.flowmvi.ui.main
+
+import com.hoc.flowmvi.domain.entity.User
+
+internal data class UserItem(
+ val id: String,
+ val email: String,
+ val avatar: String,
+ val firstName: String,
+ val lastName: String
+) {
+ val fullName get() = "$firstName $lastName"
+
+ constructor(domain: User) : this(
+ id = domain.id,
+ email = domain.email,
+ avatar = domain.avatar,
+ firstName = domain.firstName,
+ lastName = domain.lastName
+ )
+
+ fun toDomain() = User(
+ id = id,
+ lastName = lastName,
+ firstName = firstName,
+ avatar = avatar,
+ email = email
+ )
+}
+
+internal sealed class ViewIntent {
+ object Initial : ViewIntent()
+ object Refresh : ViewIntent()
+ object Retry : ViewIntent()
+ data class RemoveUser(val user: UserItem) : ViewIntent()
+}
+
+internal data class ViewState(
+ val userItems: List,
+ val isLoading: Boolean,
+ val error: Throwable?,
+ val isRefreshing: Boolean
+) {
+ companion object {
+ fun initial() = ViewState(
+ userItems = emptyList(),
+ isLoading = true,
+ error = null,
+ isRefreshing = false
+ )
+ }
+}
+
+internal sealed class PartialChange {
+ abstract fun reduce(vs: ViewState): ViewState
+
+ sealed class GetUser : PartialChange() {
+ override fun reduce(vs: ViewState): ViewState {
+ return when (this) {
+ Loading -> vs.copy(
+ isLoading = true,
+ error = null
+ )
+ is Data -> vs.copy(
+ isLoading = false,
+ error = null,
+ userItems = users
+ )
+ is Error -> vs.copy(
+ isLoading = false,
+ error = error
+ )
+ }
+ }
+
+ object Loading : GetUser()
+ data class Data(val users: List) : GetUser()
+ data class Error(val error: Throwable) : GetUser()
+ }
+
+ sealed class Refresh : PartialChange() {
+ override fun reduce(vs: ViewState): ViewState {
+ return when (this) {
+ is Success -> vs.copy(isRefreshing = false)
+ is Failure -> vs.copy(isRefreshing = false)
+ Loading -> vs.copy(isRefreshing = true)
+ }
+ }
+
+ object Loading : Refresh()
+ object Success : Refresh()
+ data class Failure(val error: Throwable) : Refresh()
+ }
+
+ sealed class RemoveUser : PartialChange() {
+ data class Success(val user: UserItem) : RemoveUser()
+ data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
+
+ override fun reduce(vs: ViewState) = vs
+ }
+}
+
+internal sealed class SingleEvent {
+ sealed class Refresh : SingleEvent() {
+ object Success : Refresh()
+ data class Failure(val error: Throwable) : Refresh()
+ }
+
+ data class GetUsersError(val error: Throwable) : SingleEvent()
+
+ sealed class RemoveUser : SingleEvent() {
+ data class Success(val user: UserItem) : RemoveUser()
+ data class Failure(val user: UserItem, val error: Throwable) : RemoveUser()
+ }
+}
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt
new file mode 100644
index 00000000..95c1ec18
--- /dev/null
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainModule.kt
@@ -0,0 +1,18 @@
+package com.hoc.flowmvi.ui.main
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.dsl.module
+
+@ExperimentalCoroutinesApi
+@FlowPreview
+val mainModule = module {
+ viewModel {
+ MainVM(
+ getUsersUseCase = get(),
+ refreshGetUsers = get(),
+ removeUser = get()
+ )
+ }
+}
diff --git a/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
new file mode 100644
index 00000000..754064c1
--- /dev/null
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/MainVM.kt
@@ -0,0 +1,133 @@
+package com.hoc.flowmvi.ui.main
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.hoc.flowmvi.core.flatMapFirst
+import com.hoc.flowmvi.domain.usecase.GetUsersUseCase
+import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase
+import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.BroadcastChannel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.filterNot
+import kotlinx.coroutines.flow.flatMapConcat
+import kotlinx.coroutines.flow.flatMapMerge
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.flow.take
+
+@Suppress("USELESS_CAST")
+@FlowPreview
+@ExperimentalCoroutinesApi
+internal class MainVM(
+ private val getUsersUseCase: GetUsersUseCase,
+ private val refreshGetUsers: RefreshGetUsersUseCase,
+ private val removeUser: RemoveUserUseCase,
+) : ViewModel() {
+ private val _eventChannel = BroadcastChannel(capacity = Channel.BUFFERED)
+ private val _intentChannel = BroadcastChannel(capacity = Channel.BUFFERED)
+
+ val viewState: StateFlow
+
+ val singleEvent: Flow
+
+ suspend fun processIntent(intent: ViewIntent) = _intentChannel.send(intent)
+
+ init {
+ val initialVS = ViewState.initial()
+
+ viewState = MutableStateFlow(initialVS)
+ singleEvent = _eventChannel.asFlow()
+
+ val intentFlow = _intentChannel.asFlow()
+ merge(
+ intentFlow.filterIsInstance().take(1),
+ intentFlow.filterNot { it is ViewIntent.Initial }
+ )
+ .toPartialChangeFlow()
+ .sendSingleEvent()
+ .scan(initialVS) { vs, change -> change.reduce(vs) }
+ .onEach { viewState.value = it }
+ .catch { }
+ .launchIn(viewModelScope)
+ }
+
+ private fun Flow.sendSingleEvent(): Flow {
+ return onEach {
+ val event = when (it) {
+ is PartialChange.GetUser.Error -> SingleEvent.GetUsersError(it.error)
+ is PartialChange.Refresh.Success -> SingleEvent.Refresh.Success
+ is PartialChange.Refresh.Failure -> SingleEvent.Refresh.Failure(it.error)
+ is PartialChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(it.user)
+ is PartialChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure(
+ user = it.user,
+ error = it.error,
+ )
+ PartialChange.GetUser.Loading -> return@onEach
+ is PartialChange.GetUser.Data -> return@onEach
+ PartialChange.Refresh.Loading -> return@onEach
+ }
+ _eventChannel.send(event)
+ }
+ }
+
+ private fun Flow.toPartialChangeFlow(): Flow {
+ val getUserChanges = getUsersUseCase()
+ .onEach { Log.d("###", "[MAIN_VM] Emit users.size=${it.size}") }
+ .map {
+ val items = it.map(::UserItem)
+ PartialChange.GetUser.Data(items) as PartialChange.GetUser
+ }
+ .onStart { emit(PartialChange.GetUser.Loading) }
+ .catch { emit(PartialChange.GetUser.Error(it)) }
+
+ val refreshChanges = refreshGetUsers::invoke
+ .asFlow()
+ .map { PartialChange.Refresh.Success as PartialChange.Refresh }
+ .onStart { emit(PartialChange.Refresh.Loading) }
+ .catch { emit(PartialChange.Refresh.Failure(it)) }
+
+ return merge(
+ filterIsInstance()
+ .logIntent()
+ .flatMapConcat { getUserChanges },
+ filterIsInstance()
+ .filter { viewState.value.let { !it.isLoading && it.error === null } }
+ .logIntent()
+ .flatMapFirst { refreshChanges },
+ filterIsInstance()
+ .filter { viewState.value.error != null }
+ .logIntent()
+ .flatMapFirst { getUserChanges },
+ filterIsInstance()
+ .logIntent()
+ .map { it.user }
+ .flatMapMerge { userItem ->
+ flow {
+ userItem
+ .toDomain()
+ .let { removeUser(it) }
+ .let { emit(it) }
+ }
+ .map { PartialChange.RemoveUser.Success(userItem) as PartialChange.RemoveUser }
+ .catch { emit(PartialChange.RemoveUser.Failure(userItem, it)) }
+ }
+ )
+ }
+
+ private fun Flow.logIntent() = onEach { Log.d("MainVM", "## Intent: $it") }
+}
diff --git a/app/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
similarity index 77%
rename from app/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
rename to feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
index 36c8e8ec..b91471df 100644
--- a/app/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
+++ b/feature-main/src/main/java/com/hoc/flowmvi/ui/main/UserAdapter.kt
@@ -7,23 +7,23 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.api.load
import coil.transform.CircleCropTransformation
-import com.hoc.flowmvi.R
-import com.hoc.flowmvi.databinding.ItemRecyclerUserBinding
-import com.hoc.flowmvi.ui.main.MainContract.UserItem
+import com.hoc.flowmvi.ui.main.databinding.ItemRecyclerUserBinding
-class UserAdapter :
- ListAdapter(object : DiffUtil.ItemCallback() {
+internal class UserAdapter :
+ ListAdapter(
+ object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: UserItem, newItem: UserItem) =
- oldItem.id == newItem.id
+ oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: UserItem, newItem: UserItem) = oldItem == newItem
- }) {
+ }
+ ) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ItemRecyclerUserBinding.inflate(
- LayoutInflater.from(parent.context),
- parent,
- false
+ LayoutInflater.from(parent.context),
+ parent,
+ false
)
return VH(binding)
}
@@ -44,4 +44,4 @@ class UserAdapter :
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/feature-main/src/main/res/drawable/ic_add_white_24dp.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_add_white_24dp.xml
rename to feature-main/src/main/res/drawable/ic_add_white_24dp.xml
diff --git a/app/src/main/res/drawable/ic_baseline_person_24.xml b/feature-main/src/main/res/drawable/ic_baseline_person_24.xml
similarity index 100%
rename from app/src/main/res/drawable/ic_baseline_person_24.xml
rename to feature-main/src/main/res/drawable/ic_baseline_person_24.xml
diff --git a/app/src/main/res/layout/activity_main.xml b/feature-main/src/main/res/layout/activity_main.xml
similarity index 95%
rename from app/src/main/res/layout/activity_main.xml
rename to feature-main/src/main/res/layout/activity_main.xml
index 3399bafd..9772a99a 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/feature-main/src/main/res/layout/activity_main.xml
@@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
- tools:context=".ui.main.MainActivity">
+ tools:context=".MainActivity">
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/item_recycler_user.xml b/feature-main/src/main/res/layout/item_recycler_user.xml
similarity index 97%
rename from app/src/main/res/layout/item_recycler_user.xml
rename to feature-main/src/main/res/layout/item_recycler_user.xml
index 567e6ca8..94efaba3 100644
--- a/app/src/main/res/layout/item_recycler_user.xml
+++ b/feature-main/src/main/res/layout/item_recycler_user.xml
@@ -53,4 +53,4 @@
app:layout_constraintStart_toEndOf="@+id/avatarImage"
app:layout_constraintTop_toBottomOf="@+id/nameTextView"
tools:text="email@gmail.com" />
-
\ No newline at end of file
+
diff --git a/app/src/main/res/menu/menu_main.xml b/feature-main/src/main/res/menu/menu_main.xml
similarity index 97%
rename from app/src/main/res/menu/menu_main.xml
rename to feature-main/src/main/res/menu/menu_main.xml
index 9f22eb7d..910d9b6c 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/feature-main/src/main/res/menu/menu_main.xml
@@ -8,4 +8,4 @@
android:title="Add"
app:showAsAction="always" />
-
\ No newline at end of file
+
diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/ExampleUnitTest.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/ExampleUnitTest.kt
new file mode 100644
index 00000000..bac2a27d
--- /dev/null
+++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/ExampleUnitTest.kt
@@ -0,0 +1,15 @@
+package com.hoc.flowmvi.ui
+
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 23339e0d..c4b40d02 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -7,15 +7,28 @@
# 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
+org.gradle.parallel=true
+
+# Enable the Build Cache
+org.gradle.caching=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
+android.enableJetifier=false
+
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+
+# Enable Kotlin incremental compilation
+kotlin.incremental=true
+
+# Enable parallel tasks execution for Kotlin Gradle plugin
+kotlin.parallel.tasks.in.project=true
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 2708670d..00000000
--- a/settings.gradle
+++ /dev/null
@@ -1,2 +0,0 @@
-rootProject.name='MVI Coroutines Flow'
-include ':app'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..16dde09a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,8 @@
+rootProject.name = "MVI Coroutines Flow"
+
+include(":app")
+include(":feature-main")
+include(":feature-add")
+include(":domain")
+include(":data")
+include(":core")