diff --git a/androidApp/src/main/kotlin/app/futured/kmptemplate/android/ui/screen/FirstScreenUi.kt b/androidApp/src/main/kotlin/app/futured/kmptemplate/android/ui/screen/FirstScreenUi.kt index 80476a93..d7af4fe9 100644 --- a/androidApp/src/main/kotlin/app/futured/kmptemplate/android/ui/screen/FirstScreenUi.kt +++ b/androidApp/src/main/kotlin/app/futured/kmptemplate/android/ui/screen/FirstScreenUi.kt @@ -37,7 +37,6 @@ import app.futured.kmptemplate.feature.ui.firstScreen.FirstViewState import app.futured.kmptemplate.resources.MR import app.futured.kmptemplate.resources.kmpStringResource import app.futured.kmptemplate.resources.localized -import dev.icerock.moko.resources.desc.desc @Composable fun FirstScreenUi( @@ -51,7 +50,7 @@ fun FirstScreenUi( Content(viewState = viewState, actions = actions, modifier = modifier) EventsEffect(eventsFlow = screen.events) { - onEvent { event -> + onEvent { event -> Toast.makeText(context, event.text.toString(context), Toast.LENGTH_SHORT).show() } } @@ -80,12 +79,12 @@ private fun Content( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(text = viewState.counter.localized()) + Text(text = viewState.counterText.localized()) Spacer(modifier = Modifier.height(4.dp)) - Text(text = viewState.createdAt.localized()) + Text(text = viewState.createdAtText.localized()) Spacer(modifier = Modifier.height(4.dp)) - AnimatedVisibility(viewState.randomPerson != null) { - viewState.randomPerson?.let { person -> + AnimatedVisibility(viewState.randomPersonText != null) { + viewState.randomPersonText?.let { person -> Column { Spacer(modifier = Modifier.height(4.dp)) Text( @@ -115,7 +114,7 @@ private fun FirstScreenPreview() { MyApplicationTheme { Surface { Content( - viewState = FirstViewState(counter = "Hey there".desc()), + viewState = FirstViewState.mock(), actions = actions, modifier = Modifier.fillMaxSize(), ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9eb23aa0..e7f7283c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ dokkaVersion = "1.9.20" google-servicesPlugin = "4.4.2" google-firebaseAppDistributionPlugin = "5.0.0" poet = "2.0.0" +turbine = "1.2.1" # Android Namespaces project-android-namespace = "app.futured.kmptemplate.android" @@ -104,6 +105,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } # Desugar androidTools-desugarLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarLibs" } diff --git a/iosApp/iosApp/Views/Screen/First/FirstView.swift b/iosApp/iosApp/Views/Screen/First/FirstView.swift index a8843594..280e4a6d 100644 --- a/iosApp/iosApp/Views/Screen/First/FirstView.swift +++ b/iosApp/iosApp/Views/Screen/First/FirstView.swift @@ -20,8 +20,8 @@ struct FirstView: View { .navigationTitle(Localizable.first_screen_title.localized) .eventsEffect(for: viewModel.events) { event in switch onEnum(of: event) { - case .showToast(let event): - viewModel.showToast(event: event) + case .notify(let event): + viewModel.notify(event: event) } } .alert(viewModel.alertText, isPresented: viewModel.isAlertVisible) { diff --git a/iosApp/iosApp/Views/Screen/First/FirstViewModel.swift b/iosApp/iosApp/Views/Screen/First/FirstViewModel.swift index e1510acc..9ad17a1e 100644 --- a/iosApp/iosApp/Views/Screen/First/FirstViewModel.swift +++ b/iosApp/iosApp/Views/Screen/First/FirstViewModel.swift @@ -10,7 +10,7 @@ protocol FirstViewModelProtocol: DynamicProperty { var alertText: String { get } func onNext() - func showToast(event: FirstUiEvent.ShowToast) + func notify(event: FirstUiEventNotify) func hideToast() } @@ -30,16 +30,17 @@ struct FirstViewModel { } extension FirstViewModel: FirstViewModelProtocol { + var counter: String { - viewState.counter.localized() + viewState.counterText.localized() } var createdAt: String { - viewState.createdAt.localized() + viewState.createdAtText.localized() } var randomPerson: String? { - viewState.randomPerson?.localized() + viewState.randomPersonText?.localized() } var isAlertVisible: Binding { @@ -53,7 +54,7 @@ extension FirstViewModel: FirstViewModelProtocol { actions.onNext() } - func showToast(event: FirstUiEvent.ShowToast) { + func notify(event: FirstUiEventNotify) { alertText = event.text.localized() alertVisible = true } diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/FlowUseCase.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/FlowUseCase.kt index 2407b2c7..e388005a 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/FlowUseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/FlowUseCase.kt @@ -1,22 +1,16 @@ package app.futured.arkitekt.crusecases import app.futured.arkitekt.crusecases.scope.FlowUseCaseExecutionScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow /** * Base [Flow] use case meant to use in [FlowUseCaseExecutionScope] implementations */ -abstract class FlowUseCase { - - /** - * [Job] used to hold and cancel existing run of this use case - */ - var job: Job? = null +interface FlowUseCase { /** * Function which builds Flow instance based on given arguments * @param args initial use case arguments */ - abstract fun build(args: ARGS): Flow + fun build(args: ARGS): Flow } diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/UseCase.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/UseCase.kt index 3f8c2dde..0eebcb7a 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/UseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/UseCase.kt @@ -1,20 +1,14 @@ package app.futured.arkitekt.crusecases import app.futured.arkitekt.crusecases.scope.SingleUseCaseExecutionScope -import kotlinx.coroutines.Deferred /** * Base Coroutine use case meant to use in [SingleUseCaseExecutionScope] implementations */ -abstract class UseCase { - - /** - * [Deferred] used to hold and cancel existing run of this use case - */ - var deferred: Deferred? = null +interface UseCase { /** * Suspend function which should contain business logic */ - abstract suspend fun build(args: ARGS): T + suspend fun build(args: ARGS): T } diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/CoroutineScopeOwner.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/CoroutineScopeOwner.kt index 1bbcd633..325a213e 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/CoroutineScopeOwner.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/CoroutineScopeOwner.kt @@ -9,7 +9,7 @@ interface CoroutineScopeOwner { * It is your responsibility to cancel it when all running * tasks should be stopped */ - val viewModelScope: CoroutineScope + val useCaseScope: CoroutineScope /** * Provides Dispatcher for background tasks. This may be overridden for testing purposes. diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseExecutionScope.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseExecutionScope.kt index 4eecbecb..f224cfa7 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseExecutionScope.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseExecutionScope.kt @@ -2,6 +2,7 @@ package app.futured.arkitekt.crusecases.scope import app.futured.arkitekt.crusecases.FlowUseCase import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -13,6 +14,11 @@ import kotlin.coroutines.cancellation.CancellationException interface FlowUseCaseExecutionScope : CoroutineScopeOwner { + /** + * Map of [Job] objects used to hold and cancel existing run of any [FlowUseCase] instance. + */ + val useCaseJobPool: MutableMap + fun FlowUseCase.execute(config: FlowUseCaseConfig.Builder.() -> Unit) = execute(Unit, config) @@ -42,10 +48,10 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { } if (flowUseCaseConfig.disposePrevious) { - job?.cancel() + useCaseJobPool[this]?.cancel() } - job = build(args) + useCaseJobPool[this] = build(args) .flowOn(getWorkerDispatcher()) .onStart { flowUseCaseConfig.onStart() } .mapNotNull { flowUseCaseConfig.onMap?.invoke(it) } @@ -65,7 +71,7 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { } } .catch { /* handled in onCompletion */ } - .launchIn(viewModelScope) + .launchIn(useCaseScope) } /** @@ -91,10 +97,10 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { } if (flowUseCaseConfig.disposePrevious) { - job?.cancel() + useCaseJobPool[this]?.cancel() } - job = build(args) + useCaseJobPool[this] = build(args) .flowOn(getWorkerDispatcher()) .onStart { flowUseCaseConfig.onStart() } .onEach { flowUseCaseConfig.onNext(it) } @@ -113,7 +119,7 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { } } .catch { /* handled in onCompletion */ } - .launchIn(viewModelScope) + .launchIn(useCaseScope) } /** diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/SingleUseCaseExecutionScope.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/SingleUseCaseExecutionScope.kt index c5c63f37..c4152f2c 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/SingleUseCaseExecutionScope.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/SingleUseCaseExecutionScope.kt @@ -1,15 +1,22 @@ package app.futured.arkitekt.crusecases.scope +import app.futured.arkitekt.crusecases.FlowUseCase import app.futured.arkitekt.crusecases.UseCase import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch interface SingleUseCaseExecutionScope : CoroutineScopeOwner { + /** + * Map of [Job] objects used to hold and cancel existing run of any [FlowUseCase] instance. + */ + val useCaseJobPool: MutableMap + /** * Asynchronously executes use case and saves it's Deferred. By default, all previous * pending executions are canceled, this can be changed by the [config]. @@ -42,19 +49,19 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner { return@run build() } if (useCaseConfig.disposePrevious) { - deferred?.cancel() + useCaseJobPool[this]?.cancel() } useCaseConfig.onStart() - deferred = viewModelScope + useCaseJobPool[this] = useCaseScope .async(context = getWorkerDispatcher(), start = CoroutineStart.LAZY) { - build(args) + runCatching { build(args) } } .also { - viewModelScope.launch(Dispatchers.Main) { + useCaseScope.launch(Dispatchers.Main) { try { - useCaseConfig.onSuccess(it.await()) - } catch (cancellation: CancellationException) { + useCaseConfig.onSuccess(it.await().getOrThrow()) + } catch (_: CancellationException) { // do nothing - this is normal way of suspend function interruption } catch (error: Throwable) { UseCaseErrorHandler.globalOnErrorLogger(error) @@ -83,15 +90,15 @@ interface SingleUseCaseExecutionScope : CoroutineScopeOwner { cancelPrevious: Boolean = true, ): Result { if (cancelPrevious) { - deferred?.cancel() + useCaseJobPool[this]?.cancel() } return try { - val newDeferred = viewModelScope.async(getWorkerDispatcher(), CoroutineStart.LAZY) { - build(args) - }.also { deferred = it } + val newDeferred = useCaseScope.async(getWorkerDispatcher(), CoroutineStart.LAZY) { + runCatching { build(args) } + }.also { useCaseJobPool[this] = it } - Result.success(newDeferred.await()) + Result.success(newDeferred.await().getOrThrow()) } catch (exception: CancellationException) { throw exception } catch (exception: Throwable) { diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseExecutionScope.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseExecutionScope.kt index f611db35..948b4886 100644 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseExecutionScope.kt +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseExecutionScope.kt @@ -14,7 +14,7 @@ interface UseCaseExecutionScope : FlowUseCaseExecutionScope { /** - * Launch suspend [block] in [viewModelScope]. + * Launch suspend [block] in [useCaseScope]. * * Encapsulates this call with try catch block and when an exception is thrown * then it is logged in [UseCaseErrorHandler.globalOnErrorLogger] and handled by [defaultErrorHandler]. @@ -25,7 +25,7 @@ interface UseCaseExecutionScope : */ @Suppress("TooGenericExceptionCaught") fun launchWithHandler(block: suspend CoroutineScope.() -> Unit) { - viewModelScope.launch { + useCaseScope.launch { try { block() } catch (exception: CancellationException) { diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/UseCaseExecutionScopeTest.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/UseCaseExecutionScopeTest.kt index 318c1249..ab12ca80 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/UseCaseExecutionScopeTest.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/UseCaseExecutionScopeTest.kt @@ -22,6 +22,7 @@ import kotlin.test.fail /** * Sanity check UseCase tests ported from [Arkitekt](https://github.com/futuredapp/arkitekt). */ +@OptIn(ExperimentalCoroutinesApi::class) class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { @BeforeTest @@ -45,13 +46,13 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { fail("Exception thrown where shouldn't") } } - viewModelScope.advanceTimeByCompat(500) + useCaseScope.advanceTimeByCompat(500) testUseCase.execute(1) { onSuccess { executionCount++ } onError { fail("Exception thrown where shouldn't") } } - viewModelScope.advanceTimeByCompat(1000) + useCaseScope.advanceTimeByCompat(1000) assertEquals(1, executionCount) } @@ -64,7 +65,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { testFailureUseCase.execute(IllegalStateException()) { onError { resultError = it } } - viewModelScope.advanceTimeByCompat(1000) + useCaseScope.advanceTimeByCompat(1000) assertNotNull(resultError) } @@ -85,7 +86,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { onNext { resultList.add(it) } onError { fail("Exception thrown where shouldn't") } } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertEquals(testingList, resultList) } @@ -100,7 +101,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { onError { fail("Exception thrown where shouldn't") } onComplete { completed = true } } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertEquals(true, completed) } @@ -115,7 +116,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { onError { resultError = it } onComplete { fail("onComplete called where shouldn't") } } - viewModelScope.advanceTimeByCompat(1000) + useCaseScope.advanceTimeByCompat(1000) assertNotNull(resultError) } @@ -125,10 +126,10 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val testUseCase = TestUseCase() var result: Result? = null - viewModelScope.launch { + useCaseScope.launch { result = testUseCase.execute(1) } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertEquals(Result.success(1), result) } @@ -138,10 +139,10 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val testUseCase = TestFailureUseCase() var result: Result? = null - viewModelScope.launch { + useCaseScope.launch { result = testUseCase.execute(IllegalStateException()) } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertTrue { result?.isFailure == true } assertTrue { result?.exceptionOrNull() is IllegalStateException } @@ -152,10 +153,10 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val testUseCase = TestFailureUseCase() var result: Result? = null - viewModelScope.launch { + useCaseScope.launch { result = testUseCase.execute(CancellationException()) } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertNull(result) } @@ -165,14 +166,14 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val testUseCase = TestUseCase() var result: Result? = null - viewModelScope.launch { + useCaseScope.launch { testUseCase.execute(1) fail("Execute should be cancelled") } - viewModelScope.launch { + useCaseScope.launch { result = testUseCase.execute(1) } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertEquals(Result.success(1), result) } @@ -183,13 +184,13 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { var result1: Result? = null var result2: Result? = null - viewModelScope.launch { + useCaseScope.launch { result1 = testUseCase.execute(1, cancelPrevious = false) } - viewModelScope.launch { + useCaseScope.launch { result2 = testUseCase.execute(2, cancelPrevious = false) } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertEquals(Result.success(1), result1) assertEquals(Result.success(2), result2) @@ -210,7 +211,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val exception = IllegalStateException() testOwner.launchWithHandler { throw exception } - testOwner.viewModelScope.advanceTimeByCompat(10000) + testOwner.useCaseScope.advanceTimeByCompat(10000) assertEquals(exception, logException) assertEquals(exception, handlerException) @@ -231,7 +232,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val exception = CancellationException() testOwner.launchWithHandler { throw exception } - testOwner.viewModelScope.advanceTimeByCompat(10000) + testOwner.useCaseScope.advanceTimeByCompat(10000) assertEquals(null, logException) assertEquals(null, handlerException) @@ -252,7 +253,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { val exception = CancellationException("Message", cause = IllegalStateException()) testOwner.launchWithHandler { throw exception } - testOwner.viewModelScope.advanceTimeByCompat(10000) + testOwner.useCaseScope.advanceTimeByCompat(10000) assertEquals(exception, logException) assertEquals(null, handlerException) @@ -272,7 +273,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { resultError = error } } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertTrue(resultError is IllegalStateException) assertTrue(logException is IllegalStateException) @@ -292,7 +293,7 @@ class UseCaseExecutionScopeTest : BaseUseCaseExecutionScopeTest() { resultError = error } } - viewModelScope.advanceTimeByCompat(10000) + useCaseScope.advanceTimeByCompat(10000) assertTrue(resultError is IllegalStateException) assertTrue(logException is IllegalStateException) diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/base/BaseUseCaseExecutionScopeTest.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/base/BaseUseCaseExecutionScopeTest.kt index 5465e1b9..8f80bdcd 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/base/BaseUseCaseExecutionScopeTest.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/base/BaseUseCaseExecutionScopeTest.kt @@ -3,6 +3,9 @@ package app.futured.arkitekt.crusecases.base import app.futured.arkitekt.crusecases.scope.UseCaseExecutionScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain @@ -10,10 +13,13 @@ import kotlinx.coroutines.test.setMain import kotlin.test.AfterTest import kotlin.test.BeforeTest +@OptIn(ExperimentalCoroutinesApi::class) abstract class BaseUseCaseExecutionScopeTest : UseCaseExecutionScope { + override val useCaseJobPool: MutableMap = mutableMapOf() + private val testDispatcher = StandardTestDispatcher() - override val viewModelScope = TestScope(testDispatcher) + override val useCaseScope = TestScope(testDispatcher) override fun getWorkerDispatcher(): CoroutineDispatcher = testDispatcher @BeforeTest @@ -23,6 +29,7 @@ abstract class BaseUseCaseExecutionScopeTest : UseCaseExecutionScope { @AfterTest fun cleanupCoroutines() { + useCaseScope.cancel() Dispatchers.resetMain() } } diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureFlowUseCase.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureFlowUseCase.kt index 8a216496..6c077fba 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureFlowUseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureFlowUseCase.kt @@ -4,7 +4,7 @@ import app.futured.arkitekt.crusecases.FlowUseCase import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -class TestFailureFlowUseCase : FlowUseCase() { +class TestFailureFlowUseCase : FlowUseCase { override fun build(args: Throwable): Flow = flow { throw args diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureUseCase.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureUseCase.kt index 21f44309..d0647339 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureUseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFailureUseCase.kt @@ -2,7 +2,7 @@ package app.futured.arkitekt.crusecases.usecases import app.futured.arkitekt.crusecases.UseCase -class TestFailureUseCase : UseCase() { +class TestFailureUseCase : UseCase { override suspend fun build(args: Throwable): Unit = throw args } diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFlowUseCase.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFlowUseCase.kt index 939930b8..dfb2a9fd 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFlowUseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestFlowUseCase.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.onEach -class TestFlowUseCase : FlowUseCase() { +class TestFlowUseCase : FlowUseCase { data class Data(val listToEmit: List, val delayBetweenEmits: Long) diff --git a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestUseCase.kt b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestUseCase.kt index a2a1248d..a0d7589b 100644 --- a/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestUseCase.kt +++ b/shared/arkitekt-cr-usecases/src/commonTest/kotlin/app/futured/arkitekt/crusecases/usecases/TestUseCase.kt @@ -3,7 +3,7 @@ package app.futured.arkitekt.crusecases.usecases import app.futured.arkitekt.crusecases.UseCase import kotlinx.coroutines.delay -class TestUseCase : UseCase() { +class TestUseCase : UseCase { override suspend fun build(args: Int): Int { delay(1000) diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt b/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt index 9635ac10..ad91c571 100644 --- a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt +++ b/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt @@ -3,17 +3,15 @@ package app.futured.arkitekt.decompose.presentation import app.futured.arkitekt.crusecases.scope.UseCaseExecutionScope import com.arkivanov.decompose.GenericComponentContext import com.arkivanov.essenty.lifecycle.doOnDestroy +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch /** @@ -24,58 +22,55 @@ import kotlinx.coroutines.launch * @param E The type of the UI events. * @param componentContext The context of the component. * @param defaultState The default Component state. + * @param lifecycleScope The coroutine scope tied to the lifecycle of the component. + * It will be automatically cancelled when component's lifecycle is destroyed. + * @param useCaseDispatcher A [CoroutineDispatcher] for executing UseCases in [UseCaseExecutionScope]. */ -abstract class BaseComponent(componentContext: GenericComponentContext<*>, private val defaultState: VS) : - UseCaseExecutionScope { - - /** - * An internal state of the component of type [VS]. - */ - protected val componentState: MutableStateFlow = MutableStateFlow(defaultState) - - // region Lifecycle +abstract class BaseComponent( + componentContext: GenericComponentContext<*>, + private val defaultState: VS, + open val lifecycleScope: CoroutineScope = MainScope(), + open val useCaseDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : UseCaseExecutionScope { + + companion object { + private const val EVENTS_EXTRA_BUFFER_CAPACITY = 64 + } - /** - * The coroutine scope tied to the lifecycle of the component. - * It is cancelled when the component is destroyed. - */ - protected val componentCoroutineScope = MainScope().also { scope -> - componentContext.lifecycle.doOnDestroy { scope.cancel() } + init { + componentContext.lifecycle.doOnDestroy { + lifecycleScope.cancel() + } } /** - * Converts a [Flow] of component states to a [StateFlow]. - * - * @param started The [SharingStarted] strategy for the [StateFlow]. - * @return A [StateFlow] emitting the values of the [Flow]. + * An internal state of the component of type [VS]. */ - protected fun Flow.asStateFlow(started: SharingStarted = SharingStarted.Lazily) = - stateIn(componentCoroutineScope, started, defaultState) - - // endregion + protected val componentState: MutableStateFlow = MutableStateFlow(defaultState) // region UI events /** - * Channel for sending UI events. + * Internal flow for sending UI events. */ - private val uiEventChannel = Channel(Channel.BUFFERED) + private val eventFlow = MutableSharedFlow(extraBufferCapacity = EVENTS_EXTRA_BUFFER_CAPACITY) /** * Flow of UI events. */ - val events: Flow = uiEventChannel - .receiveAsFlow() - .shareIn(componentCoroutineScope, SharingStarted.Lazily) + val events: Flow + get() = eventFlow // endregion // region UseCaseExecutionScope - /** - * The coroutine scope for executing use cases. - */ - override val viewModelScope: CoroutineScope = componentCoroutineScope + override val useCaseJobPool: MutableMap = mutableMapOf() + + override val useCaseScope: CoroutineScope + get() = lifecycleScope + + override fun getWorkerDispatcher(): CoroutineDispatcher = useCaseDispatcher // endregion @@ -86,9 +81,9 @@ abstract class BaseComponent(componentContext: GenericCompone * * @param event The event to send. */ - protected fun sendUiEvent(event: E) { - componentCoroutineScope.launch { - uiEventChannel.send(event) + protected fun sendEvent(event: E) { + lifecycleScope.launch { + eventFlow.emit(event) } } diff --git a/shared/feature/build.gradle.kts b/shared/feature/build.gradle.kts index 4d2e1fe3..90a3cd39 100644 --- a/shared/feature/build.gradle.kts +++ b/shared/feature/build.gradle.kts @@ -64,6 +64,8 @@ kotlin { commonTest { dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) } } } diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/CounterUseCase.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/CounterUseCase.kt index ab2a7fc5..ca98a04b 100644 --- a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/CounterUseCase.kt +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/CounterUseCase.kt @@ -9,10 +9,17 @@ import kotlinx.coroutines.isActive import org.koin.core.annotation.Factory import kotlin.time.Duration +/** + * Emits increment in intervals specified by [Args.interval]. + */ +internal fun interface CounterUseCase : FlowUseCase { + data class Args(val interval: Duration) +} + @Factory -internal class CounterUseCase : FlowUseCase() { +internal class CounterUseCaseImpl : CounterUseCase { - override fun build(args: CounterUseCaseArgs): Flow = flow { + override fun build(args: CounterUseCase.Args): Flow = flow { var counter = 0L while (currentCoroutineContext().isActive) { emit(counter++) @@ -20,5 +27,3 @@ internal class CounterUseCase : FlowUseCase() { } } } - -internal data class CounterUseCaseArgs(val interval: Duration) diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/SyncDataUseCase.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/FetchDataUseCase.kt similarity index 51% rename from shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/SyncDataUseCase.kt rename to shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/FetchDataUseCase.kt index 2677b450..c8664d53 100644 --- a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/SyncDataUseCase.kt +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/FetchDataUseCase.kt @@ -7,8 +7,14 @@ import app.futured.kmptemplate.network.rest.result.getOrThrow import org.koin.core.annotation.Factory import kotlin.random.Random +/** + * Fetches a random person from StarWars API. + */ +internal fun interface FetchDataUseCase : UseCase + @Factory -internal class SyncDataUseCase(private val starWarsApi: StarWarsApi) : UseCase() { +internal class FetchDataUseCaseImpl(private val starWarsApi: StarWarsApi) : FetchDataUseCase { - override suspend fun build(args: Unit): Person = starWarsApi.getPerson(Random.nextInt(until = 100)).getOrThrow() + override suspend fun build(args: Unit): Person = + starWarsApi.getPerson(Random.nextInt(until = 100)).getOrThrow() } diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/navigation/signedIn/SignedInNavHostComponent.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/navigation/signedIn/SignedInNavHostComponent.kt index 20944995..9774ae6a 100644 --- a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/navigation/signedIn/SignedInNavHostComponent.kt +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/navigation/signedIn/SignedInNavHostComponent.kt @@ -25,9 +25,13 @@ internal class SignedInNavHostComponent( @InjectedParam componentContext: AppComponentContext, @InjectedParam navigationToLogin: () -> Unit, @InjectedParam initialConfig: SignedInConfig, -) : AppComponent(componentContext, SignedInNavHostViewState()), +) : AppComponent(componentContext, DEFAULT_STATE), SignedInNavHost { + companion object { + val DEFAULT_STATE = SignedInNavHostViewState() + } + private val stackNavigator = StackNavigation() override val stack: StateFlow> = childStack( @@ -62,15 +66,15 @@ internal class SignedInNavHostComponent( is SignedInChild.Profile -> NavigationTab.PROFILE }, ) - }.asStateFlow() + }.stateIn(lifecycleScope, SharingStarted.Lazily, DEFAULT_STATE) override val homeTab: StateFlow = stack.map { childStack -> childStack.items.map { it.instance }.filterIsInstance().firstOrNull() - }.stateIn(componentCoroutineScope, SharingStarted.Lazily, null) + }.stateIn(lifecycleScope, SharingStarted.Lazily, null) override val profileTab: StateFlow = stack.map { childStack -> childStack.items.map { it.instance }.filterIsInstance().firstOrNull() - }.stateIn(componentCoroutineScope, SharingStarted.Lazily, null) + }.stateIn(lifecycleScope, SharingStarted.Lazily, null) override val actions: SignedInNavHost.Actions = object : SignedInNavHost.Actions { diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/base/BaseComponents.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/base/BaseComponents.kt index 01b8df1d..8d584624 100644 --- a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/base/BaseComponents.kt +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/base/BaseComponents.kt @@ -3,6 +3,10 @@ package app.futured.kmptemplate.feature.ui.base import app.futured.arkitekt.decompose.navigation.NavigationActions import app.futured.arkitekt.decompose.navigation.NavigationActionsProducer import app.futured.arkitekt.decompose.presentation.BaseComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope /** * Base class for application components - usually nav host components that are not screens and do not need to implement @@ -13,8 +17,12 @@ import app.futured.arkitekt.decompose.presentation.BaseComponent * @param componentContext The context of the component. * @param defaultState The default state of the component. */ -abstract class AppComponent(componentContext: AppComponentContext, defaultState: VS) : - BaseComponent(componentContext, defaultState), +abstract class AppComponent( + componentContext: AppComponentContext, + defaultState: VS, + lifecycleScope: CoroutineScope = MainScope(), + workerDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : BaseComponent(componentContext, defaultState, lifecycleScope, workerDispatcher), AppComponentContext by componentContext /** @@ -26,6 +34,10 @@ abstract class AppComponent(componentContext: AppComponentCon * @param componentContext The context of the component. * @param defaultState The default state of the component. */ -abstract class ScreenComponent(componentContext: AppComponentContext, defaultState: VS) : - AppComponent(componentContext, defaultState), +abstract class ScreenComponent( + componentContext: AppComponentContext, + defaultState: VS, + lifecycleScope: CoroutineScope = MainScope(), + workerDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : AppComponent(componentContext, defaultState, lifecycleScope, workerDispatcher), NavigationActionsProducer