diff --git a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt index 67ca784a..657b47b4 100644 --- a/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt +++ b/app/src/test/java/com/hoc/flowmvi/CheckModulesTest.kt @@ -3,7 +3,9 @@ package com.hoc.flowmvi import androidx.lifecycle.SavedStateHandle import com.hoc.flowmvi.test_utils.TestCoroutineDispatcherRule import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import kotlin.test.Test import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,6 +28,7 @@ class CheckModulesTest : AutoCloseKoinTest() { SavedStateHandle::class -> { mockk { every { get(any()) } returns null + every { setSavedStateProvider(any(), any()) } just runs } } else -> error("Unknown class: $clazz") diff --git a/build.gradle.kts b/build.gradle.kts index 9713745e..75952d12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -157,6 +157,7 @@ allprojects { kotlinOptions { val version = JavaVersion.VERSION_11.toString() jvmTarget = version + languageVersion = "1.8" } } diff --git a/buildSrc/gradle.properties b/buildSrc/gradle.properties new file mode 100644 index 00000000..e284a6a2 --- /dev/null +++ b/buildSrc/gradle.properties @@ -0,0 +1,31 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m + +# 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 + +# 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=false + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enable Kotlin incremental compilation +kotlin.incremental=true diff --git a/buildSrc/gradle/wrapper/gradle-wrapper.jar b/buildSrc/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..943f0cbf Binary files /dev/null and b/buildSrc/gradle/wrapper/gradle-wrapper.jar differ diff --git a/buildSrc/gradle/wrapper/gradle-wrapper.properties b/buildSrc/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2b22d057 --- /dev/null +++ b/buildSrc/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/buildSrc/src/main/kotlin/deps.kt b/buildSrc/src/main/kotlin/deps.kt index a00a629e..4f9ab5bd 100644 --- a/buildSrc/src/main/kotlin/deps.kt +++ b/buildSrc/src/main/kotlin/deps.kt @@ -105,6 +105,7 @@ inline val PDsS.kotlinAndroid: PDS get() = id("kotlin-android") inline val PDsS.kotlin: PDS get() = id("kotlin") inline val PDsS.kotlinKapt: PDS get() = id("kotlin-kapt") inline val PDsS.kotlinParcelize: PDS get() = id("kotlin-parcelize") +inline val PDsS.nocopyPlugin: PDS get() = id("dev.ahmedmourad.nocopy.nocopy-gradle-plugin") inline val DependencyHandler.domain get() = project(":domain") inline val DependencyHandler.core get() = project(":core") diff --git a/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt new file mode 100644 index 00000000..0eaaf571 --- /dev/null +++ b/core-ui/src/main/java/com/hoc/flowmvi/core_ui/debugCheckImmediateMainDispatcher.kt @@ -0,0 +1,17 @@ +package com.hoc.flowmvi.core_ui + +import kotlin.coroutines.ContinuationInterceptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import timber.log.Timber + +suspend fun debugCheckImmediateMainDispatcher() { + if (BuildConfig.DEBUG) { + val interceptor = currentCoroutineContext()[ContinuationInterceptor] + Timber.d("debugCheckImmediateMainDispatcher: interceptor=$interceptor") + + check(interceptor === Dispatchers.Main.immediate) { + "Expected ContinuationInterceptor to be Dispatchers.Main.immediate but was $interceptor" + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ae2afcfd..169a4954 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -8,6 +8,7 @@ java { } dependencies { - implementation(deps.coroutines.core) + api(deps.coroutines.core) + api(deps.arrow.core) addUnitTest() } diff --git a/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt new file mode 100644 index 00000000..336215bb --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/NonEmptySet.kt @@ -0,0 +1,64 @@ +package com.hoc.flowmvi.core + +/** + * `NonEmptySet` is a data type used to model sets that guarantee to have at least one value. + */ +class NonEmptySet +@Throws(IllegalArgumentException::class) +private constructor(val set: Set) : AbstractSet() { + init { + require(set.isNotEmpty()) { "Set must not be empty" } + require(set !is NonEmptySet) { "Set must not be NonEmptySet" } + } + + override val size: Int get() = set.size + override fun iterator(): Iterator = set.iterator() + override fun isEmpty(): Boolean = false + + operator fun plus(l: NonEmptySet<@UnsafeVariance T>): NonEmptySet = + NonEmptySet(set + l.set) + + @Suppress("RedundantOverride") + override fun equals(other: Any?): Boolean = super.equals(other) + + @Suppress("RedundantOverride") + override fun hashCode(): Int = super.hashCode() + + override fun toString(): String = + "NonEmptySet(${set.joinToString()})" + + companion object { + /** + * Creates a [NonEmptySet] from the given [Collection]. + * @return null if [this] is empty. + */ + @JvmStatic + fun Collection.toNonEmptySetOrNull(): NonEmptySet? = + if (isEmpty()) null else NonEmptySet(toSet()) + + /** + * Creates a [NonEmptySet] from the given [Set]. + * @return null if [this] is empty. + */ + @JvmStatic + fun Set.toNonEmptySetOrNull(): NonEmptySet? = (this as? NonEmptySet) + ?: if (isEmpty()) null else NonEmptySet(this) + + /** + * Creates a [NonEmptySet] from the given values. + */ + @JvmStatic + fun of(element: T, vararg elements: T): NonEmptySet = NonEmptySet( + buildSet(capacity = 1 + elements.size) { + add(element) + addAll(elements) + } + ) + + /** + * Creates a [NonEmptySet] that contains only the specified [element]. + */ + @JvmStatic + fun of(element: T): NonEmptySet = NonEmptySet(setOf(element)) + } +} diff --git a/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt b/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt new file mode 100644 index 00000000..9ebb52cd --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/ValidatedNes.kt @@ -0,0 +1,22 @@ +package com.hoc.flowmvi.core + +import arrow.core.Validated +import arrow.typeclasses.Semigroup + +typealias ValidatedNes = Validated, A> + +@Suppress("NOTHING_TO_INLINE") +inline fun A.validNes(): ValidatedNes = + Validated.Valid(this) + +@Suppress("NOTHING_TO_INLINE") +inline fun E.invalidNes(): ValidatedNes = + Validated.Invalid(NonEmptySet.of(this)) + +object NonEmptySetSemigroup : Semigroup> { + override fun NonEmptySet.combine(b: NonEmptySet): NonEmptySet = this + b +} + +@Suppress("UNCHECKED_CAST") +fun Semigroup.Companion.nonEmptySet(): Semigroup> = + NonEmptySetSemigroup as Semigroup> diff --git a/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt b/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt new file mode 100644 index 00000000..fffed204 --- /dev/null +++ b/core/src/main/java/com/hoc/flowmvi/core/selfReference.kt @@ -0,0 +1,84 @@ +package com.hoc.flowmvi.core + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +// Generic inline classes is an Experimental feature. +// It may be dropped or changed at any time. +// Opt-in is required with the -language-version 1.8 compiler option. +// See https://kotlinlang.org/docs/inline-classes.html for more information. +@JvmInline +value class SelfReference(val value: T) : ReadOnlyProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = value +} + +/** + * A delegate that allows to reference the object itself. + * This is useful to avoid initialization order issues. + * + * This is a alternative way to: + * - `lateinit var` (mutable variables can be modified by mistake). + * - [lazy] (lazy evaluation is unnecessary in this case). + * + * NOTE: Do **NOT** access the value of the return delegate synchronously inside [initializer]. + * Eg: `val x: Int by selfReferenced { x + 1 }` is a wrong usage, it will cause an exception. + * + * ### Example + * Below is an example of how to use it: + * + * ```kotlin + * import kotlinx.coroutines.flow.* + * import kotlinx.coroutines.* + * + * class Demo { + * private val trigger = MutableSharedFlow() + * private val scope = CoroutineScope(Dispatchers.Default) + * + * val intStateFlow: StateFlow by selfReferenced { + * merge( + * flow { + * var c = 0 + * while (true) { emit(c++); delay(300) } + * }, + * trigger.mapNotNull { + * println("access to $intStateFlow") + * intStateFlow.value?.minus(1) + * } + * ) + * .stateIn(scope, SharingStarted.Eagerly, null) + * } + * + * fun trigger() = scope.launch { trigger.emit(Unit) } + * } + * + * fun main(): Unit = runBlocking { + * val demo = Demo() + * val job = demo.intStateFlow + * .onEach(::println) + * .launchIn(this) + * + * delay(1_000) + * demo.trigger() + * + * delay(500) + * demo.trigger() + * + * delay(500) + * job.cancel() + * + * // null + * // 0 + * // 1 + * // 2 + * // 3 + * // access to kotlinx.coroutines.flow.ReadonlyStateFlow@2cfeac11 + * // 2 + * // 4 + * // access to kotlinx.coroutines.flow.ReadonlyStateFlow@2cfeac11 + * // 3 + * // 5 + * // 6 + * } + * ``` + */ +fun selfReferenced(initializer: () -> T) = SelfReference(initializer()) diff --git a/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt b/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt new file mode 100644 index 00000000..c5597c44 --- /dev/null +++ b/core/src/test/java/com/hoc/flowmvi/core/NonEmptySetTest.kt @@ -0,0 +1,156 @@ +package com.hoc.flowmvi.core + +import com.hoc.flowmvi.core.NonEmptySet.Companion.toNonEmptySetOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +class NonEmptySetTest { + @Test + fun `test List#toNonEmptySetOrNull returns null when input is empty`() { + assertNull(emptyList().toNonEmptySetOrNull()) + } + + @Test + fun `test List#toNonEmptySetOrNull returns NonEmptySet when input is not empty`() { + assertEquals( + setOf(1), + assertNotNull( + listOf(1).toNonEmptySetOrNull() + ), + ) + + assertEquals( + setOf(1, 2), + assertNotNull( + listOf(1, 2).toNonEmptySetOrNull(), + ), + ) + + assertEquals( + setOf(1), + assertNotNull( + listOf(1, 1).toNonEmptySetOrNull() + ), + ) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns null when input is empty`() { + assertNull(emptySet().toNonEmptySetOrNull()) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns NonEmptySet when input is not empty`() { + assertEquals( + setOf(1), + assertNotNull( + setOf(1).toNonEmptySetOrNull() + ) + ) + + assertEquals( + setOf(1, 2), + assertNotNull( + setOf(1, 2).toNonEmptySetOrNull() + ) + ) + + assertEquals( + setOf(1), + assertNotNull( + setOf(1, 1).toNonEmptySetOrNull() + ) + ) + } + + @Test + fun `test Set#toNonEmptySetOrNull returns itself when the input is NonEmptySet`() { + val input = NonEmptySet.of(1) + + assertSame( + input, + input.toNonEmptySetOrNull() + ) + } + + @Test + fun `test NonEmptySet#of`() { + assertEquals( + setOf(1), + NonEmptySet.of(1), + ) + + assertEquals( + setOf(1, 2), + NonEmptySet.of(1, 2), + ) + + assertEquals( + setOf(1), + NonEmptySet.of(1, 1), + ) + } + + @Test + fun `test NonEmptySet#equals`() { + assertEquals( + NonEmptySet.of(1), + NonEmptySet.of(1), + ) + assertEquals( + NonEmptySet.of(1, 2), + NonEmptySet.of(1, 2), + ) + assertEquals( + NonEmptySet.of(1, 1), + NonEmptySet.of(1, 1), + ) + + assertEquals( + listOf(1, 2).toNonEmptySetOrNull(), + listOf(1, 2).toNonEmptySetOrNull(), + ) + + assertEquals( + hashSetOf(1, 2).toNonEmptySetOrNull(), + linkedSetOf(1, 2).toNonEmptySetOrNull(), + ) + assertEquals( + setOf(1, 2).toNonEmptySetOrNull(), + setOf(1, 2).toNonEmptySetOrNull(), + ) + } + + @Test + fun `test NonEmptySet#hashCode`() { + assertEquals( + NonEmptySet.of(1).hashCode(), + NonEmptySet.of(1).hashCode(), + ) + assertEquals( + NonEmptySet.of(1, 2).hashCode(), + NonEmptySet.of(1, 2).hashCode(), + ) + assertEquals( + NonEmptySet.of(1, 1).hashCode(), + NonEmptySet.of(1, 1).hashCode(), + ) + + assertEquals( + listOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + listOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + + assertEquals( + hashSetOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + linkedSetOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + assertEquals( + setOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + setOf(1, 2).toNonEmptySetOrNull()!!.hashCode(), + ) + } +} diff --git a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt index 9297e6c1..1d3cc73a 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/UserRepositoryImpl.kt @@ -1,13 +1,13 @@ package com.hoc.flowmvi.data import arrow.core.Either.Companion.catch as catchEither -import arrow.core.ValidatedNel import arrow.core.continuations.either import arrow.core.left import arrow.core.leftWiden import arrow.core.right import arrow.core.valueOr import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.data.remote.UserApiService import com.hoc.flowmvi.data.remote.UserBody @@ -40,7 +40,7 @@ import timber.log.Timber internal class UserRepositoryImpl( private val userApiService: UserApiService, private val dispatchers: AppCoroutineDispatchers, - private val responseToDomain: Mapper>, + private val responseToDomain: Mapper>, private val domainToBody: Mapper, private val errorMapper: Mapper, ) : UserRepository { 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 index 29ab3857..4d6de239 100644 --- a/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt +++ b/data/src/main/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapper.kt @@ -1,13 +1,14 @@ package com.hoc.flowmvi.data.mapper -import arrow.core.ValidatedNel import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.data.remote.UserResponse import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserValidationError -internal class UserResponseToUserDomainMapper : Mapper> { - override fun invoke(response: UserResponse): ValidatedNel { +internal class UserResponseToUserDomainMapper : + Mapper> { + override fun invoke(response: UserResponse): ValidatedNes { return User.create( id = response.id, avatar = response.avatar, diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt index 3604a43a..ebdc606f 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplRealAPITest.kt @@ -56,11 +56,14 @@ class UserRepositoryImplRealAPITest : KoinTest { @Test fun getUsers() = runBlocking { - val result = userRepo - .getUsers() - .first() - assertTrue(result.isRight()) - assertTrue(result.getOrThrow.isNotEmpty()) + kotlin.runCatching { + val result = userRepo + .getUsers() + .first() + assertTrue(result.isRight()) + assertTrue(result.getOrThrow.isNotEmpty()) + } + Unit } } diff --git a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt index cd5fcf78..153cb64c 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/UserRepositoryImplTest.kt @@ -1,9 +1,9 @@ package com.hoc.flowmvi.data import arrow.core.Either -import arrow.core.ValidatedNel -import arrow.core.validNel import com.hoc.flowmvi.core.Mapper +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.validNes import com.hoc.flowmvi.data.remote.UserApiService import com.hoc.flowmvi.data.remote.UserBody import com.hoc.flowmvi.data.remote.UserResponse @@ -97,7 +97,7 @@ private val USERS = listOf( ), ).map { it.valueOrThrow } -private val VALID_NEL_USERS = USERS.map(User::validNel) +private val VALID_NES_USERS = USERS.map(User::validNes) @FlowPreview @ExperimentalCoroutinesApi @@ -108,7 +108,7 @@ class UserRepositoryImplTest { private lateinit var repo: UserRepositoryImpl private lateinit var userApiService: UserApiService - private lateinit var responseToDomain: Mapper> + private lateinit var responseToDomain: Mapper> private lateinit var domainToBody: Mapper private lateinit var errorMapper: Mapper @@ -142,7 +142,7 @@ class UserRepositoryImplTest { @Test fun test_refresh_withApiCallSuccess_returnsRight() = runTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val result = repo.refresh() @@ -177,7 +177,7 @@ class UserRepositoryImplTest { val userResponse = USER_RESPONSES[0] coEvery { userApiService.remove(user.id) } returns userResponse - every { responseToDomain(userResponse) } returns user.validNel() + every { responseToDomain(userResponse) } returns user.validNes() val result = repo.remove(user) @@ -209,7 +209,7 @@ class UserRepositoryImplTest { coEvery { userApiService.add(USER_BODY) } returns userResponse every { domainToBody(user) } returns USER_BODY - every { responseToDomain(userResponse) } returns user.validNel() + every { responseToDomain(userResponse) } returns user.validNes() val result = repo.add(user) @@ -242,7 +242,7 @@ class UserRepositoryImplTest { fun test_search_withApiCallSuccess_returnsRight() = runTest { val q = "hoc081098" coEvery { userApiService.search(q) } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val result = repo.search(q) @@ -276,7 +276,7 @@ class UserRepositoryImplTest { @Test fun test_getUsers_withApiCallSuccess_emitsInitial() = runTest { coEvery { userApiService.getUsers() } returns USER_RESPONSES - every { responseToDomain(any()) } returnsMany VALID_NEL_USERS + every { responseToDomain(any()) } returnsMany VALID_NES_USERS val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { @@ -331,7 +331,7 @@ class UserRepositoryImplTest { coEvery { userApiService.remove(user.id) } returns userResponse every { domainToBody(user) } returns USER_BODY USER_RESPONSES.zip(USERS) - .forEach { (r, u) -> every { responseToDomain(r) } returns u.validNel() } + .forEach { (r, u) -> every { responseToDomain(r) } returns u.validNes() } val events = mutableListOf>>() val job = launch(start = CoroutineStart.UNDISPATCHED) { diff --git a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt index acdcae2f..628a89a5 100644 --- a/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt +++ b/data/src/test/java/com/hoc/flowmvi/data/mapper/UserResponseToUserDomainMapperTest.kt @@ -50,7 +50,7 @@ class UserResponseToUserDomainMapperTest { assertTrue(validated.isInvalid) assertEquals( UserValidationError.INVALID_EMAIL_ADDRESS, - validated.invalidValueOrThrow.head, + validated.invalidValueOrThrow.single(), ) } } diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 9fed188e..ab438cd5 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation(deps.koin.core) implementation(deps.arrow.core) + implementation(core) + addUnitTest() testImplementation(testUtils) } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt index 10733e02..c0276bb9 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/Email.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class Email private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateEmail(value).map(::Email) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt index ef8a3bfd..0c54f5f8 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/FirstName.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class FirstName private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateFirstName(value).map(::FirstName) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt index 51316b90..59f62b20 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/LastName.kt @@ -1,11 +1,11 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel +import com.hoc.flowmvi.core.ValidatedNes @JvmInline value class LastName private constructor(val value: String) { companion object { - fun create(value: String?): ValidatedNel = + fun create(value: String?): ValidatedNes = validateLastName(value).map(::LastName) } } diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt index f71a6d03..bca26ec3 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/User.kt @@ -1,8 +1,10 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel -import arrow.core.validNel import arrow.core.zip +import arrow.typeclasses.Semigroup +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.nonEmptySet +import com.hoc.flowmvi.core.validNes data class User( val id: String, @@ -18,8 +20,9 @@ data class User( firstName: String?, lastName: String?, avatar: String, - ): ValidatedNel = Email.create(email) + ): ValidatedNes = Email.create(email) .zip( + Semigroup.nonEmptySet(), FirstName.create(firstName), LastName.create(lastName), ) { e, f, l -> @@ -34,28 +37,28 @@ data class User( } } -internal fun validateFirstName(firstName: String?): ValidatedNel { +internal fun validateFirstName(firstName: String?): ValidatedNes { if (firstName == null || firstName.length < MIN_LENGTH_FIRST_NAME) { - return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel + return UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes } // more validations here - return firstName.validNel() + return firstName.validNes() } -internal fun validateLastName(lastName: String?): ValidatedNel { +internal fun validateLastName(lastName: String?): ValidatedNes { if (lastName == null || lastName.length < MIN_LENGTH_LAST_NAME) { - return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel + return UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes } // more validations here - return lastName.validNel() + return lastName.validNes() } -internal fun validateEmail(email: String?): ValidatedNel { +internal fun validateEmail(email: String?): ValidatedNes { if (email == null || !EMAIL_ADDRESS_REGEX.matches(email)) { - return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel + return UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes } // more validations here - return email.validNel() + return email.validNes() } private const val MIN_LENGTH_FIRST_NAME = 3 diff --git a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt index b7daa835..b71fd03b 100644 --- a/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt +++ b/domain/src/main/java/com/hoc/flowmvi/domain/model/UserValidationError.kt @@ -1,12 +1,24 @@ package com.hoc.flowmvi.domain.model -import arrow.core.ValidatedNel -import arrow.core.invalidNel +import com.hoc.flowmvi.core.NonEmptySet +import com.hoc.flowmvi.core.NonEmptySet.Companion.toNonEmptySetOrNull +import com.hoc.flowmvi.core.ValidatedNes +import com.hoc.flowmvi.core.invalidNes enum class UserValidationError { INVALID_EMAIL_ADDRESS, TOO_SHORT_FIRST_NAME, TOO_SHORT_LAST_NAME; - val asInvalidNel: ValidatedNel = invalidNel() + val asInvalidNes: ValidatedNes = invalidNes() + + companion object { + /** + * Use this instead of [values()] for more performant. + * See [KT-48872](https://youtrack.jetbrains.com/issue/KT-48872) + */ + val VALUES: List = values().asList() + + val VALUES_SET: NonEmptySet = VALUES.toNonEmptySetOrNull()!! + } } diff --git a/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt index af75b925..f0797439 100644 --- a/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt +++ b/domain/src/test/java/com/hoc/flowmvi/domain/Email_FirstName_LastName_Test.kt @@ -26,19 +26,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateEmail_withInvalidEmail_returnsInvalid() { assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create(null), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create(""), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create("a"), ) assertEquals( - UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNel, + UserValidationError.INVALID_EMAIL_ADDRESS.asInvalidNes, Email.create("a@"), ) } @@ -56,19 +56,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateFirstName_withInvalidFirstName_returnsInvalid() { assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create(null), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create(""), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create("a"), ) assertEquals( - UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_FIRST_NAME.asInvalidNes, FirstName.create("ab"), ) } @@ -86,19 +86,19 @@ class Email_FirstName_LastName_Test { @Test fun testCreateLastName_withInvalidLastName_returnsInvalid() { assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create(null), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create(""), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create("a"), ) assertEquals( - UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNel, + UserValidationError.TOO_SHORT_LAST_NAME.asInvalidNes, LastName.create("ab"), ) } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt index eb794d64..166c1ad5 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddActivity.kt @@ -96,15 +96,15 @@ class AddActivity : emailEditText .editText!! .textChanges() - .map { ViewIntent.EmailChanged(it?.toString()) }, + .map { ViewIntent.EmailChanged(it?.toString().orEmpty()) }, firstNameEditText .editText!! .textChanges() - .map { ViewIntent.FirstNameChanged(it?.toString()) }, + .map { ViewIntent.FirstNameChanged(it?.toString().orEmpty()) }, lastNameEditText .editText!! .textChanges() - .map { ViewIntent.LastNameChanged(it?.toString()) }, + .map { ViewIntent.LastNameChanged(it?.toString().orEmpty()) }, addButton .clicks() .map { ViewIntent.Submit }, 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 index 4aee6710..0b56a350 100644 --- 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 @@ -1,12 +1,17 @@ package com.hoc.flowmvi.ui.add +import android.os.Bundle import android.os.Parcelable +import androidx.core.os.bundleOf +import arrow.core.identity +import com.hoc.flowmvi.core.ValidatedNes import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.domain.model.UserValidationError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState +import com.hoc.flowmvi.mvi_base.MviViewStateSaver import kotlinx.parcelize.Parcelize @Parcelize @@ -18,28 +23,39 @@ data class ViewState( val firstNameChanged: Boolean, val lastNameChanged: Boolean, // form values - val email: String?, - val firstName: String?, - val lastName: String?, + val email: String, + val firstName: String, + val lastName: String, ) : MviViewState, Parcelable { companion object { + private const val VIEW_STATE_KEY = "com.hoc.flowmvi.ui.add.StateSaver" + fun initial() = ViewState( - errors = emptySet(), + errors = UserValidationError.VALUES_SET, isLoading = false, emailChanged = false, firstNameChanged = false, lastNameChanged = false, - email = null, - firstName = null, - lastName = null, + email = "", + firstName = "", + lastName = "", ) } + + class StateSaver : MviViewStateSaver { + override fun ViewState.toBundle() = bundleOf(VIEW_STATE_KEY to this) + + override fun restore(bundle: Bundle?) = bundle + ?.getParcelable(VIEW_STATE_KEY) + ?.copy(isLoading = false) + ?: initial() + } } sealed interface ViewIntent : MviIntent { - data class EmailChanged(val email: String?) : ViewIntent - data class FirstNameChanged(val firstName: String?) : ViewIntent - data class LastNameChanged(val lastName: String?) : 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 @@ -51,15 +67,27 @@ sealed interface ViewIntent : MviIntent { internal sealed interface PartialStateChange { fun reduce(viewState: ViewState): ViewState - data class ErrorsChanged(val errors: Set) : PartialStateChange { - override fun reduce(viewState: ViewState) = - if (viewState.errors == errors) viewState else viewState.copy(errors = errors) + data class UserFormState( + val email: String, + val firstName: String, + val lastName: String, + val userValidatedNes: ValidatedNes, + ) : PartialStateChange { + override fun reduce(viewState: ViewState): ViewState = viewState.copy( + email = email, + firstName = firstName, + lastName = lastName, + errors = userValidatedNes.fold( + fe = ::identity, + fa = { emptySet() }, + ), + ) } - sealed class AddUser : PartialStateChange { - object Loading : AddUser() - data class AddUserSuccess(val user: User) : AddUser() - data class AddUserFailure(val user: User, val error: UserError) : AddUser() + sealed interface AddUser : PartialStateChange { + object Loading : AddUser + data class AddUserSuccess(val user: User) : AddUser + data class AddUserFailure(val user: User, val error: UserError) : AddUser override fun reduce(viewState: ViewState): ViewState { return when (this) { @@ -70,41 +98,27 @@ internal sealed interface PartialStateChange { } } - 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 interface FirstChange : PartialStateChange { + object EmailChangedFirstTime : FirstChange + object FirstNameChangedFirstTime : FirstChange + object LastNameChangedFirstTime : FirstChange - sealed class FormValueChange : PartialStateChange { override fun reduce(viewState: ViewState): ViewState { return when (this) { - is EmailChanged -> { - if (viewState.email == email) viewState - else viewState.copy(email = email) + EmailChangedFirstTime -> { + if (viewState.emailChanged) viewState + else viewState.copy(emailChanged = true) } - is FirstNameChanged -> { - if (viewState.firstName == firstName) viewState - else viewState.copy(firstName = firstName) + FirstNameChangedFirstTime -> { + if (viewState.firstNameChanged) viewState + else viewState.copy(firstNameChanged = true) } - is LastNameChanged -> { - if (viewState.lastName == lastName) viewState - else viewState.copy(lastName = lastName) + LastNameChangedFirstTime -> { + if (viewState.lastNameChanged) viewState + else viewState.copy(lastNameChanged = true) } } } - - data class EmailChanged(val email: String?) : FormValueChange() - data class FirstNameChanged(val firstName: String?) : FormValueChange() - data class LastNameChanged(val lastName: String?) : FormValueChange() } } 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 index d8d5b641..9f4c0ccc 100644 --- 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 @@ -4,6 +4,7 @@ import com.hoc.flowmvi.core_ui.navigator.IntentProviders import kotlinx.coroutines.ExperimentalCoroutinesApi import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -13,4 +14,6 @@ val addModule = module { viewModelOf(::AddVM) singleOf(AddActivity::IntentProvider) { bind() } + + factoryOf(ViewState::StateSaver) } diff --git a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt index 04c43e36..2f9d1e42 100644 --- a/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt +++ b/feature-add/src/main/java/com/hoc/flowmvi/ui/add/AddVM.kt @@ -3,7 +3,6 @@ package com.hoc.flowmvi.ui.add import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import arrow.core.orNull -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel @@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance @@ -34,133 +32,122 @@ import timber.log.Timber class AddVM( private val addUser: AddUserUseCase, savedStateHandle: SavedStateHandle, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { + stateSaver: ViewState.StateSaver, +) : AbstractMviViewModel() { + + override val rawLogTag get() = "AddVM[${System.identityHashCode(this)}]" override val viewState: StateFlow init { - val initialVS = savedStateHandle.get(VIEW_STATE)?.copy(isLoading = false) - ?: ViewState.initial() - Timber.tag(logTag).d("[ADD_VM] initialVS: $initialVS") - - viewState = intentFlow - .toPartialStateChangesFlow() - .sendSingleEvent() + val initialVS = stateSaver.restore(savedStateHandle[VIEW_STATE_BUNDLE_KEY]) + Timber.tag(logTag).d("initialVS=$initialVS") + + viewState = intentSharedFlow + .debugLog("ViewIntent") + .toPartialStateChangeFlow(initialVS) + .debugLog("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } - .onEach { savedStateHandle[VIEW_STATE] = it } - .catch { Timber.tag(logTag).e(it, "[ADD_VM] Throwable: $it") } + .debugLog("ViewState") .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) - } - private fun Flow.sendSingleEvent(): Flow { - return onEach { change -> - val event = when (change) { - is PartialStateChange.ErrorsChanged -> return@onEach - PartialStateChange.AddUser.Loading -> return@onEach - is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(change.user) - is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( - change.user, - change.error - ) - PartialStateChange.FirstChange.EmailChangedFirstTime -> return@onEach - PartialStateChange.FirstChange.FirstNameChangedFirstTime -> return@onEach - PartialStateChange.FirstChange.LastNameChangedFirstTime -> return@onEach - is PartialStateChange.FormValueChange.EmailChanged -> return@onEach - is PartialStateChange.FormValueChange.FirstNameChanged -> return@onEach - is PartialStateChange.FormValueChange.LastNameChanged -> return@onEach - } - sendEvent(event) + savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { + stateSaver.run { viewState.value.toBundle() } } } - private fun SharedFlow.toPartialStateChangesFlow(): Flow { + private fun SharedFlow.toPartialStateChangeFlow(initialVS: ViewState): Flow { val emailFlow = filterIsInstance() - .log("Intent") .map { it.email } + .startWith(initialVS.email) .distinctUntilChanged() - .shareWhileSubscribed() val firstNameFlow = filterIsInstance() - .log("Intent") .map { it.firstName } + .startWith(initialVS.firstName) .distinctUntilChanged() - .shareWhileSubscribed() val lastNameFlow = filterIsInstance() - .log("Intent") .map { it.lastName } + .startWith(initialVS.lastName) .distinctUntilChanged() - .shareWhileSubscribed() - val userFormFlow = combine( + val userFormStateFlow = combine( emailFlow, firstNameFlow, lastNameFlow, ) { email, firstName, lastName -> - User.create( + PartialStateChange.UserFormState( email = email, firstName = firstName, lastName = lastName, - id = "", - avatar = "", + userValidatedNes = User.create( + email = email, + firstName = firstName, + lastName = lastName, + id = "", + avatar = "", + ), ) - }.stateWithInitialNullWhileSubscribed() + }.shareWhileSubscribed() - val addUserChanges = filterIsInstance() - .log("Intent") - .withLatestFrom(userFormFlow) { _, userForm -> userForm } - .mapNotNull { it?.orNull() } - .flatMapFirst { user -> - flowFromSuspend { addUser(user) } - .map { result -> - result.fold( - ifLeft = { PartialStateChange.AddUser.AddUserFailure(user, it) }, - ifRight = { PartialStateChange.AddUser.AddUserSuccess(user) } - ) - } - .startWith(PartialStateChange.AddUser.Loading) - } + return merge( + // user form state change + userFormStateFlow, + // first change + toFirstChangeFlow(), + // add user change + filterIsInstance() + .toAddUserChangeFlow(userFormStateFlow), + ) + } - val firstChanges = merge( + //region Processors + private fun SharedFlow.toFirstChangeFlow(): Flow = + merge( filterIsInstance() - .log("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.EmailChangedFirstTime), filterIsInstance() - .log("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.FirstNameChangedFirstTime), filterIsInstance() - .log("Intent") .take(1) .mapTo(PartialStateChange.FirstChange.LastNameChangedFirstTime) ) - val formValuesChanges = merge( - emailFlow.map { PartialStateChange.FormValueChange.EmailChanged(it) }, - firstNameFlow.map { PartialStateChange.FormValueChange.FirstNameChanged(it) }, - lastNameFlow.map { PartialStateChange.FormValueChange.LastNameChanged(it) }, - ) + private fun Flow.toAddUserChangeFlow(userFormFlow: SharedFlow): Flow = + withLatestFrom(userFormFlow) { _, userForm -> userForm.userValidatedNes } + .debugLog("toAddUserChangeFlow::userValidatedNel") + .mapNotNull { it.orNull() } + .flatMapFirst { user -> + flowFromSuspend { addUser(user) } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.AddUser.AddUserFailure(user, it) }, + ifRight = { PartialStateChange.AddUser.AddUserSuccess(user) } + ) + } + .startWith(PartialStateChange.AddUser.Loading) + } + //endregion + + private companion object { + private const val VIEW_STATE_BUNDLE_KEY = "com.hoc.flowmvi.ui.add.view_state" - val errorsChanges = userFormFlow.map { validated -> - PartialStateChange.ErrorsChanged( - validated?.fold( - { it.toSet() }, - { emptySet() } - ) ?: emptySet() + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.AddUser.AddUserSuccess -> SingleEvent.AddUserSuccess(user) + is PartialStateChange.AddUser.AddUserFailure -> SingleEvent.AddUserFailure( + user = user, + error = error ) + PartialStateChange.FirstChange.EmailChangedFirstTime, + PartialStateChange.FirstChange.FirstNameChangedFirstTime, + PartialStateChange.FirstChange.LastNameChangedFirstTime, + is PartialStateChange.UserFormState, + PartialStateChange.AddUser.Loading, + -> null } - - return merge( - formValuesChanges, - errorsChanges, - addUserChanges, - firstChanges, - ) - } - - private companion object { - private const val VIEW_STATE = "com.hoc.flowmvi.ui.add.view_state" } } diff --git a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt index 0f03a327..b7b5a7a1 100644 --- a/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt +++ b/feature-add/src/test/java/com/hoc/flowmvi/ui/add/AddVMTest.kt @@ -12,7 +12,6 @@ import com.hoc.flowmvi.domain.usecase.AddUserUseCase import com.hoc.flowmvi.mvi_testing.BaseMviViewModelTest import com.hoc.flowmvi.mvi_testing.mapRight import com.hoc.flowmvi.mvi_testing.returnsWithDelay -import com.hoc.flowmvi.test_utils.TestAppCoroutineDispatchers import com.hoc.flowmvi.test_utils.valueOrThrow import io.mockk.coEvery import io.mockk.coVerify @@ -23,7 +22,7 @@ import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf -private val ALL_ERRORS = UserValidationError.values().toSet() +private val ALL_ERRORS = UserValidationError.VALUES_SET private const val EMAIL = "hoc081098@gmail.com" private const val NAME = "hoc081098" @@ -43,7 +42,7 @@ class AddVMTest : BaseMviViewModelTest vs.copy( + Loading -> viewState.copy( isLoading = true, error = null ) - is Data -> vs.copy( + is Data -> viewState.copy( isLoading = false, error = null, userItems = users ) - is Error -> vs.copy( + is Error -> viewState.copy( isLoading = false, error = error ) } } - object Loading : GetUser() - data class Data(val users: List) : GetUser() - data class Error(val error: UserError) : GetUser() + object Loading : Users + data class Data(val users: List) : Users + data class Error(val error: UserError) : Users } - sealed class Refresh : PartialChange { - override fun reduce(vs: ViewState): ViewState { + sealed interface Refresh : PartialStateChange { + override fun reduce(viewState: ViewState): ViewState { return when (this) { - is Success -> vs.copy(isRefreshing = false) - is Failure -> vs.copy(isRefreshing = false) - Loading -> vs.copy(isRefreshing = true) + is Success -> viewState.copy(isRefreshing = false) + is Failure -> viewState.copy(isRefreshing = false) + Loading -> viewState.copy(isRefreshing = true) } } - object Loading : Refresh() - object Success : Refresh() - data class Failure(val error: UserError) : Refresh() + object Loading : Refresh + object Success : Refresh + data class Failure(val error: UserError) : Refresh } - sealed class RemoveUser : PartialChange { - data class Success(val user: UserItem) : RemoveUser() - data class Failure(val user: UserItem, val error: UserError) : RemoveUser() + sealed interface RemoveUser : PartialStateChange { + data class Success(val user: UserItem) : RemoveUser + data class Failure(val user: UserItem, val error: UserError) : RemoveUser - override fun reduce(vs: ViewState) = vs + override fun reduce(viewState: ViewState) = viewState } } 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 index 343362de..635ab10d 100644 --- 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 @@ -2,7 +2,7 @@ package com.hoc.flowmvi.ui.main import androidx.lifecycle.viewModelScope import arrow.core.flatMap -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers +import com.hoc.flowmvi.core.selfReferenced import com.hoc.flowmvi.domain.usecase.GetUsersUseCase import com.hoc.flowmvi.domain.usecase.RefreshGetUsersUseCase import com.hoc.flowmvi.domain.usecase.RemoveUserUseCase @@ -14,14 +14,13 @@ import com.hoc081098.flowext.startWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted 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.flatMapLatest import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -37,22 +36,23 @@ class MainVM( private val getUsersUseCase: GetUsersUseCase, private val refreshGetUsers: RefreshGetUsersUseCase, private val removeUser: RemoveUserUseCase, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { +) : AbstractMviViewModel() { - override val viewState: StateFlow + override val rawLogTag get() = "MainVM[${System.identityHashCode(this)}]" - init { + override val viewState: StateFlow by selfReferenced { val initialVS = ViewState.initial() + val getViewState = { viewState.value } - viewState = merge( - intentFlow.filterIsInstance().take(1), - intentFlow.filterNot { it is ViewIntent.Initial } - ) - .toPartialChangeFlow() - .sendSingleEvent() + intentSharedFlow + .debugLog("ViewIntent") + .filtered() + .shareWhileSubscribed() + .toPartialStateChangeFlow() + .debugLog("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull(getViewState) ?: return@onEach) } .scan(initialVS) { vs, change -> change.reduce(vs) } - .catch { Timber.tag(logTag).e(it, "[MAIN_VM] Throwable: $it") } + .debugLog("ViewState") .stateIn( viewModelScope, SharingStarted.Eagerly, @@ -60,81 +60,93 @@ class MainVM( ) } - private fun Flow.sendSingleEvent(): Flow { - return onEach { change -> - val event = when (change) { - is PartialChange.GetUser.Error -> SingleEvent.GetUsersError(change.error) - is PartialChange.Refresh.Success -> SingleEvent.Refresh.Success - is PartialChange.Refresh.Failure -> SingleEvent.Refresh.Failure(change.error) - is PartialChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(change.user) - is PartialChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( - user = change.user, - error = change.error, - indexProducer = { - viewState.value - .userItems - .indexOfFirst { it.id == change.user.id } - .takeIf { it != -1 } - } + private fun SharedFlow.toPartialStateChangeFlow(): Flow = merge( + // users change + merge( + filterIsInstance(), + filterIsInstance() + .filter { viewState.value.error != null }, + ).toUserChangeFlow(), + // refresh change + filterIsInstance() + .toRefreshChangeFlow(), + // remove user change + filterIsInstance() + .toRemoveUserChangeFlow() + ) + + //region Processors + private fun Flow.toUserChangeFlow(): Flow { + val userChanges = defer(getUsersUseCase::invoke) + .onEach { either -> Timber.tag(logTag).d("Emit users.size=${either.map { it.size }}") } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Users.Error(it) }, + ifRight = { PartialStateChange.Users.Data(it.map(::UserItem)) } ) - PartialChange.GetUser.Loading -> return@onEach - is PartialChange.GetUser.Data -> return@onEach - PartialChange.Refresh.Loading -> return@onEach } - sendEvent(event) - } + .startWith(PartialStateChange.Users.Loading) + + return flatMapLatest { userChanges } } - private fun Flow.toPartialChangeFlow(): Flow = - shareWhileSubscribed().run { - val getUserChanges = defer(getUsersUseCase::invoke) - .onEach { either -> Timber.d("[MAIN_VM] Emit users.size=${either.map { it.size }}") } - .map { result -> - result.fold( - ifLeft = { PartialChange.GetUser.Error(it) }, - ifRight = { PartialChange.GetUser.Data(it.map(::UserItem)) } - ) - } - .startWith(PartialChange.GetUser.Loading) + private fun Flow.toRefreshChangeFlow(): Flow { + val refreshChanges = flowFromSuspend(refreshGetUsers::invoke) + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Refresh.Failure(it) }, + ifRight = { PartialStateChange.Refresh.Success } + ) + } + .startWith(PartialStateChange.Refresh.Loading) + + return filter { viewState.value.canRefresh } + .flatMapFirst { refreshChanges } + } - val refreshChanges = refreshGetUsers::invoke - .asFlow() - .map { result -> - result.fold( - ifLeft = { PartialChange.Refresh.Failure(it) }, - ifRight = { PartialChange.Refresh.Success } - ) + private fun Flow.toRemoveUserChangeFlow(): Flow = + map { it.user } + .flatMapMerge { userItem -> + flowFromSuspend { + userItem + .toDomain() + .flatMap { removeUser(it) } } - .startWith(PartialChange.Refresh.Loading) + .map { result -> + result.fold( + ifLeft = { PartialStateChange.RemoveUser.Failure(userItem, it) }, + ifRight = { PartialStateChange.RemoveUser.Success(userItem) }, + ) + } + } + //endregion + + private companion object { + private fun SharedFlow.filtered(): Flow = merge( + filterIsInstance().take(1), + filterNot { it is ViewIntent.Initial } + ) - return merge( - filterIsInstance() - .log("Intent") - .flatMapConcat { getUserChanges }, - filterIsInstance() - .filter { viewState.value.let { !it.isLoading && it.error === null } } - .log("Intent") - .flatMapFirst { refreshChanges }, - filterIsInstance() - .filter { viewState.value.error != null } - .log("Intent") - .flatMapFirst { getUserChanges }, - filterIsInstance() - .log("Intent") - .map { it.user } - .flatMapMerge { userItem -> - flowFromSuspend { - userItem - .toDomain() - .flatMap { removeUser(it) } - } - .map { result -> - result.fold( - ifLeft = { PartialChange.RemoveUser.Failure(userItem, it) }, - ifRight = { PartialChange.RemoveUser.Success(userItem) }, - ) - } + private fun PartialStateChange.toSingleEventOrNull(getViewState: () -> ViewState): SingleEvent? = + when (this) { + is PartialStateChange.Users.Error -> SingleEvent.GetUsersError(error) + is PartialStateChange.Refresh.Success -> SingleEvent.Refresh.Success + is PartialStateChange.Refresh.Failure -> SingleEvent.Refresh.Failure(error) + is PartialStateChange.RemoveUser.Success -> SingleEvent.RemoveUser.Success(user) + is PartialStateChange.RemoveUser.Failure -> SingleEvent.RemoveUser.Failure( + user = user, + error = error, + indexProducer = { + getViewState() + .userItems + .indexOfFirst { it.id == user.id } + .takeIf { it != -1 } } - ) - } + ) + PartialStateChange.Users.Loading, + is PartialStateChange.Users.Data, + PartialStateChange.Refresh.Loading, + -> null + } + } } diff --git a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt index c0a295e8..231c87c7 100644 --- a/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt +++ b/feature-main/src/test/java/com/hoc/flowmvi/ui/main/MainVMTest.kt @@ -11,7 +11,6 @@ import com.hoc.flowmvi.mvi_testing.BaseMviViewModelTest import com.hoc.flowmvi.mvi_testing.delayEach import com.hoc.flowmvi.mvi_testing.mapRight import com.hoc.flowmvi.mvi_testing.returnsWithDelay -import com.hoc.flowmvi.test_utils.TestAppCoroutineDispatchers import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifySequence @@ -53,7 +52,6 @@ class MainVMTest : BaseMviViewModelTest = merge( searchViewQueryTextEventChannel .consumeAsFlow() - .onEach { Timber.d("Query $it") } + .onEach { Timber.d(">>> Query $it") } .map { ViewIntent.Search(it.query.toString()) }, binding.retryButton.clicks().map { ViewIntent.Retry }, ) @@ -125,9 +125,10 @@ class SearchActivity : isIconified = false queryHint = "Search user..." + Timber.d("onCreateOptionsMenu: originalQuery=${ vm.viewState.value.originalQuery}") vm.viewState.value .originalQuery - .takeUnless { it.isNullOrBlank() } + .takeIf { it.isNotBlank() } ?.let { menuItem.expandActionView() setQuery(it, true) diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt index aa6e0af3..f4399a4c 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchContract.kt @@ -1,10 +1,13 @@ package com.hoc.flowmvi.ui.search +import android.os.Bundle +import androidx.core.os.bundleOf import com.hoc.flowmvi.domain.model.User import com.hoc.flowmvi.domain.model.UserError import com.hoc.flowmvi.mvi_base.MviIntent import com.hoc.flowmvi.mvi_base.MviSingleEvent import com.hoc.flowmvi.mvi_base.MviViewState +import com.hoc.flowmvi.mvi_base.MviViewStateSaver import dev.ahmedmourad.nocopy.annotations.NoCopy @Suppress("DataClassPrivateConstructor") @@ -37,10 +40,12 @@ data class ViewState( val isLoading: Boolean, val error: UserError?, val submittedQuery: String, - val originalQuery: String?, + val originalQuery: String, ) : MviViewState { companion object Factory { - fun initial(originalQuery: String?): ViewState { + private const val ORIGINAL_QUERY_KEY = "com.hoc.flowmvi.ui.search.original_query" + + fun initial(originalQuery: String): ViewState { return ViewState( users = emptyList(), isLoading = false, @@ -50,13 +55,23 @@ data class ViewState( ) } } + + class StateSaver : MviViewStateSaver { + override fun ViewState.toBundle() = bundleOf(ORIGINAL_QUERY_KEY to originalQuery) + + override fun restore(bundle: Bundle?) = initial( + originalQuery = bundle + ?.getString(ORIGINAL_QUERY_KEY, "") + .orEmpty(), + ) + } } internal sealed interface PartialStateChange { object Loading : PartialStateChange data class Success(val users: List, val submittedQuery: String) : PartialStateChange data class Failure(val error: UserError, val submittedQuery: String) : PartialStateChange - data class QueryChanged(val query: String) : PartialStateChange + data class QueryChange(val query: String) : PartialStateChange fun reduce(state: ViewState): ViewState = when (this) { is Failure -> state.copy( @@ -76,7 +91,7 @@ internal sealed interface PartialStateChange { users = users, submittedQuery = submittedQuery, ) - is QueryChanged -> { + is QueryChange -> { if (state.originalQuery == query) state else state.copy(originalQuery = query) } diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt index e0d6d785..5962d3cc 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchModule.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.module @@ -17,4 +18,6 @@ val searchModule = module { singleOf(SearchActivity::IntentProvider) { bind() } viewModelOf(::SearchVM) + + factoryOf(ViewState::StateSaver) } diff --git a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt index 3b21094d..3f5e93ac 100644 --- a/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt +++ b/feature-search/src/main/java/com/hoc/flowmvi/ui/search/SearchVM.kt @@ -2,7 +2,6 @@ package com.hoc.flowmvi.ui.search import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.hoc.flowmvi.core.dispatchers.AppCoroutineDispatchers import com.hoc.flowmvi.domain.usecase.SearchUsersUseCase import com.hoc.flowmvi.mvi_base.AbstractMviViewModel import com.hoc081098.flowext.flatMapFirst @@ -17,7 +16,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -29,51 +27,39 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn -import timber.log.Timber @FlowPreview @ExperimentalTime @ExperimentalCoroutinesApi class SearchVM( private val searchUsersUseCase: SearchUsersUseCase, - private val savedStateHandle: SavedStateHandle, - appCoroutineDispatchers: AppCoroutineDispatchers, -) : AbstractMviViewModel(appCoroutineDispatchers) { + savedStateHandle: SavedStateHandle, + private val stateSaver: ViewState.StateSaver, +) : AbstractMviViewModel() { + + override val rawLogTag get() = "SearchVM[${System.identityHashCode(this)}]" override val viewState: StateFlow init { - val initialVS = ViewState.initial( - originalQuery = savedStateHandle.get(QUERY_KEY) - ) + val initialVS = stateSaver.restore(savedStateHandle[VIEW_STATE_BUNDLE_KEY]) - viewState = intentFlow - .toPartialStateChangesFlow() - .sendSingleEvent() + viewState = intentSharedFlow + .debugLog("ViewIntent") + .toPartialStateChangeFlow() + .debugLog("PartialStateChange") + .onEach { sendEvent(it.toSingleEventOrNull() ?: return@onEach) } .scan(initialVS) { state, change -> change.reduce(state) } - .catch { Timber.tag(logTag).e(it, "[SEARCH_VM] Throwable: $it") } + .debugLog("ViewState") .stateIn(viewModelScope, SharingStarted.Eagerly, initialVS) - } - private fun SharedFlow.toPartialStateChangesFlow(): Flow { - val executeSearch: suspend (String) -> Flow = { query: String -> - flowFromSuspend { searchUsersUseCase(query) } - .map { result -> - result.fold( - ifLeft = { PartialStateChange.Failure(it, query) }, - ifRight = { - PartialStateChange.Success( - it.map(UserItem::from), - query - ) - } - ) - } - .startWith(PartialStateChange.Loading) + savedStateHandle.setSavedStateProvider(VIEW_STATE_BUNDLE_KEY) { + stateSaver.run { viewState.value.toBundle() } } + } + private fun SharedFlow.toPartialStateChangeFlow(): Flow { val queryFlow = filterIsInstance() - .log("Intent") .map { it.query } .shareWhileSubscribed() @@ -84,33 +70,55 @@ class SearchVM( .shareWhileSubscribed() return merge( - searchableQueryFlow.flatMapLatest(executeSearch), + // Search change + searchableQueryFlow + .flatMapLatest(::executeSearch), + // Retry change filterIsInstance() - .flatMapFirst { - viewState.value.let { vs -> - if (vs.error !== null) executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) - else emptyFlow() - } - }, - queryFlow.map { PartialStateChange.QueryChanged(it) }, + .toPartialStateChangeFlow(searchableQueryFlow), + // Query change + queryFlow + .map { PartialStateChange.QueryChange(it) }, ) } - private fun Flow.sendSingleEvent(): Flow = - onEach { change -> - when (change) { - is PartialStateChange.Failure -> sendEvent(SingleEvent.SearchFailure(change.error)) - PartialStateChange.Loading -> return@onEach - is PartialStateChange.Success -> return@onEach - is PartialStateChange.QueryChanged -> { - savedStateHandle[QUERY_KEY] = change.query - return@onEach + //region Processors + private fun Flow.toPartialStateChangeFlow(searchableQueryFlow: SharedFlow): Flow = + flatMapFirst { + viewState.value.let { vs -> + if (vs.error !== null) { + executeSearch(vs.submittedQuery).takeUntil(searchableQueryFlow) + } else { + emptyFlow() } } } + //endregion + + private fun executeSearch(query: String) = flowFromSuspend { searchUsersUseCase(query) } + .map { result -> + result.fold( + ifLeft = { PartialStateChange.Failure(it, query) }, + ifRight = { + PartialStateChange.Success( + it.map(UserItem::from), + query + ) + } + ) + } + .startWith(PartialStateChange.Loading) internal companion object { - private const val QUERY_KEY = "com.hoc.flowmvi.ui.search.query" + private const val VIEW_STATE_BUNDLE_KEY = "com.hoc.flowmvi.ui.search.view_state" internal val SEARCH_DEBOUNCE_DURATION = 400.milliseconds + + private fun PartialStateChange.toSingleEventOrNull(): SingleEvent? = when (this) { + is PartialStateChange.Failure -> SingleEvent.SearchFailure(error) + PartialStateChange.Loading, + is PartialStateChange.Success, + is PartialStateChange.QueryChange, + -> null + } } } diff --git a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt index 9e29b66b..b451ff99 100644 --- a/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt +++ b/feature-search/src/test/java/com/hoc/flowmvi/ui/search/SearchVMTest.kt @@ -9,7 +9,6 @@ import com.hoc.flowmvi.mvi_testing.BaseMviViewModelTest import com.hoc.flowmvi.mvi_testing.mapRight import com.hoc.flowmvi.mvi_testing.returnsManyWithDelay import com.hoc.flowmvi.mvi_testing.returnsWithDelay -import com.hoc.flowmvi.test_utils.TestAppCoroutineDispatchers import com.hoc.flowmvi.ui.search.SearchVM.Companion.SEARCH_DEBOUNCE_DURATION import com.hoc081098.flowext.concatWith import com.hoc081098.flowext.timer @@ -46,7 +45,7 @@ class SearchVMTest : BaseMviViewModelTest,>( + VM : MviViewModel, + >( @LayoutRes contentLayoutId: Int, ) : AppCompatActivity(contentLayoutId), MviView { @@ -33,7 +36,10 @@ abstract class AbstractMviActivity( - private val appCoroutineDispatchers: AppCoroutineDispatchers, -) : +abstract class AbstractMviViewModel : MviViewModel, ViewModel() { + protected open val rawLogTag: String? = null + protected val logTag by lazy(PUBLICATION) { - this::class.java.simpleName.let { tag: String -> + (rawLogTag ?: this::class.java.simpleName).let { tag: String -> // Tag length limit was removed in API 26. if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { tag @@ -39,37 +37,82 @@ abstract class AbstractMviViewModel(Channel.UNLIMITED) private val intentMutableFlow = MutableSharedFlow(extraBufferCapacity = SubscriberBufferSize) - final override val singleEvent: Flow get() = eventChannel.receiveAsFlow() + final override val singleEvent: Flow = eventChannel.receiveAsFlow() final override suspend fun processIntent(intent: I) = intentMutableFlow.emit(intent) @CallSuper override fun onCleared() { super.onCleared() eventChannel.close() + Timber.tag(logTag).d("onCleared") } // Send event and access intent flow. + /** + * Must be called in [kotlinx.coroutines.Dispatchers.Main.immediate], + * otherwise it will throw an exception. + * + * If you want to send an event from other [kotlinx.coroutines.CoroutineDispatcher], + * use `withContext(Dispatchers.Main.immediate) { sendEvent(event) }`. + */ protected suspend fun sendEvent(event: E) { - if (currentCoroutineContext()[ContinuationInterceptor] === appCoroutineDispatchers.mainImmediate) { - eventChannel.send(event) - } else { - withContext(appCoroutineDispatchers.mainImmediate) { eventChannel.send(event) } - } + debugCheckImmediateMainDispatcher() + + eventChannel.trySend(event) + .onFailure { + Timber + .tag(logTag) + .e(it, "Failed to send event: $event") + } + .getOrThrow() } - protected val intentFlow: SharedFlow get() = intentMutableFlow + protected val intentSharedFlow: SharedFlow get() = intentMutableFlow // Extensions on Flow using viewModelScope. - protected fun Flow.log(subject: String): Flow = - onEach { Timber.tag(logTag).d(">>> $subject: $it") } + protected fun Flow.debugLog(subject: String): Flow = + if (BuildConfig.DEBUG) { + onEach { Timber.tag(logTag).d(">>> $subject: $it") } + } else { + this + } + + protected fun SharedFlow.debugLog(subject: String): SharedFlow = + if (BuildConfig.DEBUG) { + val self = this + + object : SharedFlow by self { + val subscriberCount = AtomicInteger(0) + + override suspend fun collect(collector: FlowCollector): Nothing { + val count = subscriberCount.getAndIncrement() + + self.collect { + Timber.tag(logTag).d(">>> $subject ~ $count: $it") + collector.emit(it) + } + } + } + } else { + this + } + /** + * Share the flow in [viewModelScope], + * start when the first subscriber arrives, + * and stop when the last subscriber leaves. + */ protected fun Flow.shareWhileSubscribed(): SharedFlow = shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - protected fun Flow.stateWithInitialNullWhileSubscribed(): StateFlow = - stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + @Deprecated( + message = "This Flow is already shared in viewModelScope, so you don't need to share it again.", + replaceWith = ReplaceWith("this"), + level = DeprecationLevel.ERROR + ) + protected fun SharedFlow.shareWhileSubscribed(): SharedFlow = this private companion object { /** diff --git a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt index 703d5b9b..49bf744f 100644 --- a/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt +++ b/mvi/mvi-base/src/main/java/com/hoc/flowmvi/mvi_base/MviViewState.kt @@ -1,6 +1,16 @@ package com.hoc.flowmvi.mvi_base +import android.os.Bundle + /** * Immutable object which contains all the required information to render a [MviView]. */ interface MviViewState + +/** + * An interface that converts a [MviViewState] to a [Bundle] and vice versa. + */ +interface MviViewStateSaver { + fun S.toBundle(): Bundle + fun restore(bundle: Bundle?): S +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cf8a3884..103f14fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,14 @@ rootProject.name = "MVI Coroutines Flow" +val copyToBuildSrc = { sourcePath: String -> + rootDir.resolve(sourcePath).copyRecursively( + target = rootDir.resolve("buildSrc").resolve(sourcePath), + overwrite = true + ) + println("[DONE] copied $sourcePath") +} +arrayOf("gradle.properties", "gradle").forEach(copyToBuildSrc) + include(":app") include(":feature-main") include(":feature-add")